Compare commits

...

2 Commits

@ -1,14 +1,9 @@
package org.xtimms.shirizu.core.components package org.xtimms.shirizu.core.components
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
@ -32,13 +27,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DetailsToolbar( fun ModernDetailsToolbar(
title: String, title: String,
titleAlphaProvider: () -> Float, titleAlphaProvider: () -> Float,
navigateBack: () -> Unit, navigateBack: () -> Unit,
@ -119,3 +113,75 @@ fun DetailsToolbar(
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClassicDetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
navigateBack: () -> Unit,
navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
var expanded by remember { mutableStateOf(false) }
Column(
modifier = modifier
) {
TopAppBar(
navigationIcon = {
BackIconButton(
onClick = navigateBack
)
},
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = titleAlphaProvider()),
)
},
actions = {
IconButton(
onClick = { expanded = true }
) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Share") },
onClick = { /*TODO*/ },
leadingIcon = {
Icon(imageVector = Icons.Default.Share, contentDescription = null)
}
)
DropdownMenuItem(
text = { Text("Download") },
onClick = { /*TODO*/ },
leadingIcon = {
Icon(imageVector = Icons.Outlined.Download, contentDescription = null)
}
)
DropdownMenuItem(
text = { Text("Open in web browser") },
onClick = {
navigateToWebBrowser()
expanded = false
},
leadingIcon = {
Icon(imageVector = Icons.Outlined.Language, contentDescription = null)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(alpha = backgroundAlphaProvider())
)
)
}
}

@ -27,9 +27,9 @@ fun SettingTitle(text: String) {
Text( Text(
modifier = Modifier modifier = Modifier
.padding(top = 32.dp) .padding(top = 32.dp)
.padding(horizontal = 20.dp, vertical = 16.dp), .padding(horizontal = 16.dp, vertical = 16.dp),
text = text, text = text,
style = MaterialTheme.typography.displaySmall style = MaterialTheme.typography.headlineLarge
) )
} }

@ -22,8 +22,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QueryStats
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material.icons.outlined.RssFeed import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.SentimentSatisfiedAlt import androidx.compose.material.icons.outlined.SentimentSatisfiedAlt
@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -63,6 +64,7 @@ import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.DURATION_ENTER import org.xtimms.shirizu.core.DURATION_ENTER
import org.xtimms.shirizu.core.DURATION_EXIT import org.xtimms.shirizu.core.DURATION_EXIT
import org.xtimms.shirizu.core.initialOffset import org.xtimms.shirizu.core.initialOffset
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.toEasing import org.xtimms.shirizu.core.toEasing
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
@ -167,6 +169,7 @@ fun TopAppBar(
Row( Row(
modifier = modifier.padding(end = 16.dp), modifier = modifier.padding(end = 16.dp),
) { ) {
if (AppSettings.isTrackerEnabled()) {
IconButton( IconButton(
onClick = { navController.navigate(FEED_DESTINATION) }, onClick = { navController.navigate(FEED_DESTINATION) },
modifier = Modifier.padding(0.dp), modifier = Modifier.padding(0.dp),
@ -196,7 +199,7 @@ fun TopAppBar(
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Default.QueryStats, imageVector = Icons.Outlined.QueryStats,
contentDescription = stringResource(id = R.string.statistics) contentDescription = stringResource(id = R.string.statistics)
) )
} }
@ -215,6 +218,28 @@ fun TopAppBar(
} }
) )
} }
} else {
IconButton(
onClick = { navController.navigate(STATS_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.QueryStats,
contentDescription = stringResource(id = R.string.statistics),
tint = MaterialTheme.colorScheme.outline
)
}
IconButton(
onClick = { navController.navigate(SETTINGS_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.Settings,
contentDescription = stringResource(id = R.string.settings),
tint = MaterialTheme.colorScheme.outline
)
}
}
} }
} }
} }
@ -271,18 +296,36 @@ fun SmallTopAppBarWithChips(
} }
} }
private val path = Path().apply {
moveTo(0f,0f)
lineTo(0.7f, 0.1f)
cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F)
moveTo(1f,1f)
}
val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SmallTopAppBar( fun SmallTopAppBar(
title: String, modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null, titleText: String = "",
navigateBack: () -> Unit, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
) { title: @Composable () -> Unit = {
MediumTopAppBar( Text(
title = { Text(text = title) }, text = titleText,
navigationIcon = { color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction(scrollBehavior.state.overlappedFraction)),
BackIconButton(onClick = navigateBack) maxLines = 1
)
}, },
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
) {
androidx.compose.material3.TopAppBar(
modifier = modifier,
title = title,
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }

@ -35,9 +35,10 @@ import org.xtimms.shirizu.core.database.entity.TagEntity
import org.xtimms.shirizu.core.database.entity.TrackEntity import org.xtimms.shirizu.core.database.entity.TrackEntity
import org.xtimms.shirizu.core.database.entity.TrackLogEntity import org.xtimms.shirizu.core.database.entity.TrackLogEntity
import org.xtimms.shirizu.core.database.migrations.Migration1To2 import org.xtimms.shirizu.core.database.migrations.Migration1To2
import org.xtimms.shirizu.core.database.migrations.Migration2To3
import org.xtimms.shirizu.utils.lang.processLifecycleScope import org.xtimms.shirizu.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 2 const val DATABASE_VERSION = 3
@Database( @Database(
entities = [ entities = [
@ -83,7 +84,8 @@ abstract class ShirizuDatabase : RoomDatabase() {
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2() Migration1To2(),
Migration2To3()
) )
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room fun ShirizuDatabase(context: Context): ShirizuDatabase = Room

@ -51,6 +51,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @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> abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1)")
abstract suspend fun findIdsWithTrack(): LongArray
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
@ -107,6 +110,9 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0") @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> abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
abstract suspend fun findCategoriesIdsWithTrack(mangaId: Long): List<Long>
/** INSERT **/ /** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

@ -54,6 +54,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
abstract suspend fun findAllIds(): LongArray
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
@ -74,6 +77,9 @@ abstract class HistoryDao {
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0") @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
abstract fun observeCount(): Flow<Int> abstract fun observeCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
abstract suspend fun getCount(): Int
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0") @Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
abstract suspend fun findProgress(id: Long): Float? abstract suspend fun findProgress(id: Long): Float?

@ -28,6 +28,9 @@ interface TrackLogsDao {
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc() suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs") @Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int suspend fun count(): Int
} }

@ -8,6 +8,7 @@ import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.xtimms.shirizu.core.database.entity.MangaWithTags import org.xtimms.shirizu.core.database.entity.MangaWithTags
import org.xtimms.shirizu.core.database.entity.TrackEntity import org.xtimms.shirizu.core.database.entity.TrackEntity
import org.xtimms.shirizu.core.database.entity.TrackWithManga
@Dao @Dao
abstract class TracksDao { abstract class TracksDao {
@ -15,6 +16,13 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks") @Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity> abstract suspend fun findAll(): List<TrackEntity>
@Transaction
@Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<TrackWithManga>
@Query("SELECT manga_id FROM tracks")
abstract suspend fun findAllIds(): LongArray
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)") @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity> abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@ -24,6 +32,9 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int? abstract suspend fun findNewChapters(mangaId: Long): Int?
@Query("SELECT COUNT(*) FROM tracks")
abstract suspend fun getTracksCount(): Int
@Query("SELECT manga_id, chapters_new FROM tracks") @Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn( abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(
columnName = "chapters_new" columnName = "chapters_new"

@ -19,11 +19,26 @@ import androidx.room.PrimaryKey
class TrackEntity( class TrackEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING)
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long, @ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int, @ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long, @ColumnInfo(name = "last_check_time") val lastCheckTime: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING) @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long,
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long @ColumnInfo(name = "last_result") val lastResult: Int,
) {
companion object {
const val RESULT_NONE = 0
const val RESULT_HAS_UPDATE = 1
const val RESULT_NO_UPDATE = 2
const val RESULT_FAILED = 3
fun create(mangaId: Long) = TrackEntity(
mangaId = mangaId,
lastChapterId = 0L,
newChapters = 0,
lastCheckTime = 0L,
lastChapterDate = 0,
lastResult = RESULT_NONE,
) )
}
}

@ -21,5 +21,5 @@ class TrackLogEntity(
@ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String, @ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "created_at") val createdAt: Long,
) )

@ -0,0 +1,13 @@
package org.xtimms.shirizu.core.database.entity
import androidx.room.Embedded
import androidx.room.Relation
class TrackWithManga(
@Embedded val track: TrackEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id",
)
val manga: MangaEntity,
)

@ -0,0 +1,16 @@
package org.xtimms.shirizu.core.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
db.execSQL("DROP TABLE tracks")
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
db.execSQL("DROP TABLE tracks_bk")
}
}

@ -59,6 +59,7 @@ const val RELATED = "related"
const val TRACKER = "tracker" const val TRACKER = "tracker"
const val CELLULAR_DOWNLOAD = "cellular_download" const val CELLULAR_DOWNLOAD = "cellular_download"
const val STATISTICS = "statistics" const val STATISTICS = "statistics"
const val MODERN_VIEW = "modern_view"
const val PROXY_TYPE = "proxy_type" const val PROXY_TYPE = "proxy_type"
const val PROXY_ADDRESS = "proxy_address" const val PROXY_ADDRESS = "proxy_address"
@ -170,6 +171,8 @@ object AppSettings {
fun isStatisticsEnabled() = STATISTICS.getBoolean() fun isStatisticsEnabled() = STATISTICS.getBoolean()
fun isModernViewEnabled() = MODERN_VIEW.getBoolean(true)
fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float { fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float {
return when (columns) { return when (columns) {
1 -> 1f 1 -> 1f

@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet import androidx.collection.MutableLongSet
import coil.request.CachePolicy import coil.request.CachePolicy
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.getPreferredBranch import org.xtimms.shirizu.core.model.getPreferredBranch
import org.xtimms.shirizu.core.parser.MangaRepository import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository import org.xtimms.shirizu.core.parser.RemoteMangaRepository
@ -26,47 +27,19 @@ class Tracker @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
suspend fun getAllTracks(): List<TrackingItem> { suspend fun getTracks(limit: Int): List<TrackingItem> {
val knownManga = MutableLongSet() repository.updateTracks()
val result = ArrayList<TrackingItem>() return repository.getTracks(0, limit).map {
// Favourites val categoryId = repository.getCategoryId(it.manga.id)
val favourites = repository.getAllFavouritesManga() TrackingItem(
channels.updateChannels(favourites.keys) tracking = it,
for ((category, mangaList) in favourites) { channelId = if (categoryId == 0L) {
if (!category.isTrackingEnabled || mangaList.isEmpty()) {
continue
}
val categoryTracks = repository.getTracks(mangaList)
val channelId = if (channels.isFavouriteNotificationsEnabled(category)) {
channels.getFavouritesChannelId(category.id)
} else {
null
}
for (track in categoryTracks) {
if (knownManga.add(track.manga.id)) {
result.add(TrackingItem(track, channelId))
}
}
}
// History
val history = repository.getAllHistoryManga()
val historyTracks = repository.getTracks(history)
val channelId = if (channels.isHistoryNotificationsEnabled()) {
channels.getHistoryChannelId() channels.getHistoryChannelId()
} else { } else {
null channels.getFavouritesChannelId(categoryId)
} },
for (track in historyTracks) { )
if (knownManga.add(track.manga.id)) {
result.add(TrackingItem(track, channelId))
}
}
result.trimToSize()
return result
} }
suspend fun getTracks(ids: Set<Long>): List<TrackingItem> {
return getAllTracks().filterTo(ArrayList(ids.size)) { x -> x.tracking.manga.id in ids }
} }
suspend fun gc() { suspend fun gc() {
@ -76,11 +49,18 @@ class Tracker @Inject constructor(
suspend fun fetchUpdates( suspend fun fetchUpdates(
track: MangaTracking, track: MangaTracking,
commit: Boolean commit: Boolean
): MangaUpdates.Success = withMangaLock(track.manga.id) { ): MangaUpdates = withMangaLock(track.manga.id) {
val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source) val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" } require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY) val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
val updates = compare(track, manga, getBranch(manga)) compare(track, manga, getBranch(manga))
}.getOrElse { error ->
MangaUpdates.Failure(
manga = track.manga,
error = error
)
}
if (commit) { if (commit) {
repository.saveUpdates(updates) repository.saveUpdates(updates)
} }

@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.tracker.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.xtimms.shirizu.core.exceptions.TooManyRequestExceptions import org.xtimms.shirizu.core.exceptions.TooManyRequestExceptions
import org.xtimms.shirizu.utils.lang.ifZero
sealed interface MangaUpdates { sealed interface MangaUpdates {
@ -16,6 +17,15 @@ sealed interface MangaUpdates {
) : MangaUpdates { ) : MangaUpdates {
fun isNotEmpty() = newChapters.isNotEmpty() fun isNotEmpty() = newChapters.isNotEmpty()
fun lastChapterDate(): Long {
val lastChapter = newChapters.lastOrNull()
return if (lastChapter == null) {
manga.chapters?.lastOrNull()?.uploadDate ?: 0L
} else {
lastChapter.uploadDate.ifZero { System.currentTimeMillis() }
}
}
} }
data class Failure( data class Failure(

@ -1,4 +1,4 @@
package org.xtimms.shirizu.core.screens package org.xtimms.shirizu.core.ui.screens
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement

@ -1,4 +1,4 @@
package org.xtimms.shirizu.core.screens package org.xtimms.shirizu.core.ui.screens
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode

@ -1,4 +1,4 @@
package org.xtimms.shirizu.core.screens package org.xtimms.shirizu.core.ui.screens
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize

@ -19,7 +19,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.screens.InfoScreen import org.xtimms.shirizu.core.ui.screens.InfoScreen
import org.xtimms.shirizu.ui.theme.ShirizuTheme import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.utils.system.toast import org.xtimms.shirizu.utils.system.toast

@ -21,8 +21,6 @@ import org.xtimms.shirizu.utils.ReversibleHandle
import org.xtimms.shirizu.utils.lang.mapItems import org.xtimms.shirizu.utils.lang.mapItems
import javax.inject.Inject import javax.inject.Inject
const val PROGRESS_NONE = -1f
@Reusable @Reusable
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
private val db: ShirizuDatabase, private val db: ShirizuDatabase,
@ -34,6 +32,10 @@ class HistoryRepository @Inject constructor(
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
} }
suspend fun getCount(): Int {
return db.getHistoryDao().getCount()
}
suspend fun getLastOrNull(): Manga? { suspend fun getLastOrNull(): Manga? {
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags()) return entity.manga.toManga(entity.tags.toMangaTags())

@ -1,7 +1,6 @@
package org.xtimms.shirizu.data.repository package org.xtimms.shirizu.data.repository
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -12,7 +11,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.shirizu.core.database.ShirizuDatabase import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.database.entity.MangaEntity import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.database.entity.TrackEntity import org.xtimms.shirizu.core.database.entity.TrackEntity
@ -21,19 +19,23 @@ import org.xtimms.shirizu.core.database.entity.toFavouriteCategory
import org.xtimms.shirizu.core.database.entity.toManga import org.xtimms.shirizu.core.database.entity.toManga
import org.xtimms.shirizu.core.database.entity.toTrackingLogItem import org.xtimms.shirizu.core.database.entity.toTrackingLogItem
import org.xtimms.shirizu.core.model.FavouriteCategory import org.xtimms.shirizu.core.model.FavouriteCategory
import org.xtimms.shirizu.core.model.isLocal
import org.xtimms.shirizu.core.parser.local.LocalMangaRepository import org.xtimms.shirizu.core.parser.local.LocalMangaRepository
import org.xtimms.shirizu.core.tracker.model.MangaTracking import org.xtimms.shirizu.core.tracker.model.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.utils.lang.ifZero
import org.xtimms.shirizu.utils.lang.mapItems import org.xtimms.shirizu.utils.lang.mapItems
import java.time.Instant import org.xtimms.shirizu.utils.lang.toInstantOrNull
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
private const val NO_ID = 0L private const val NO_ID = 0L
@Deprecated("Use buckets")
private const val MAX_QUERY_IDS = 100 private const val MAX_QUERY_IDS = 100
private const val MAX_BUCKET_SIZE = 20
private const val MAX_LOG_SIZE = 120
@Reusable @Reusable
class TrackingRepository @Inject constructor( class TrackingRepository @Inject constructor(
@ -66,35 +68,18 @@ class TrackingRepository @Inject constructor(
.onStart { gcIfNotCalled() } .onStart { gcIfNotCalled() }
} }
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> { suspend fun getCategoryId(mangaId: Long): Long {
val ids = mangaList.mapToSet { it.id } return db.getFavouritesDao().findCategoriesIdsWithTrack(mangaId).firstOrNull() ?: NO_ID
val dao = db.getTracksDao()
val tracks = if (ids.size <= MAX_QUERY_IDS) {
dao.findAll(ids)
} else {
// TODO split tracks in the worker
ids.windowed(MAX_QUERY_IDS, MAX_QUERY_IDS, true)
.flatMap { dao.findAll(it) }
}.groupBy { it.mangaId }
val idSet = MutableLongSet(mangaList.size)
val result = ArrayList<MangaTracking>(mangaList.size)
for (item in mangaList) {
val manga = if (item.isLocal) {
localMangaRepositoryProvider.get().getRemoteManga(item) ?: continue
} else {
item
} }
if (!idSet.add(manga.id)) {
continue suspend fun getTracks(offset: Int, limit: Int): List<MangaTracking> {
} return db.getTracksDao().findAll(offset, limit).map {
val track = tracks[manga.id]?.lastOrNull() MangaTracking(
result += MangaTracking( manga = it.manga.toManga(emptySet()),
manga = manga, lastChapterId = it.track.lastChapterId,
lastChapterId = track?.lastChapterId ?: NO_ID, lastCheck = it.track.lastCheckTime.toInstantOrNull(),
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli),
) )
} }
return result
} }
@VisibleForTesting @VisibleForTesting
@ -103,7 +88,7 @@ class TrackingRepository @Inject constructor(
return MangaTracking( return MangaTracking(
manga = manga, manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli), lastCheck = track?.lastCheckTime?.toInstantOrNull(),
) )
} }
@ -133,16 +118,19 @@ class TrackingRepository @Inject constructor(
suspend fun clearCounters() = db.getTracksDao().clearCounters() suspend fun clearCounters() = db.getTracksDao().clearCounters()
suspend fun gc() { suspend fun gc() = db.withTransaction {
db.getTracksDao().gc() db.getTracksDao().gc()
db.getTrackLogsDao().gc() db.getTrackLogsDao().run {
gc()
trim(MAX_LOG_SIZE)
}
} }
suspend fun saveUpdates(updates: MangaUpdates.Success) { suspend fun saveUpdates(updates: MangaUpdates) {
db.withTransaction { db.withTransaction {
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.getTracksDao().upsert(track) db.getTracksDao().upsert(track)
if (updates.isValid && updates.newChapters.isNotEmpty()) { if (updates is MangaUpdates.Success && updates.isValid && updates.newChapters.isNotEmpty()) {
updatePercent(updates) updatePercent(updates)
val logEntity = TrackLogEntity( val logEntity = TrackLogEntity(
mangaId = updates.manga.id, mangaId = updates.manga.id,
@ -174,7 +162,6 @@ class TrackingRepository @Inject constructor(
val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID
val entity = TrackEntity( val entity = TrackEntity(
mangaId = manga.id, mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = lastChapterId, lastChapterId = lastChapterId,
newChapters = when { newChapters = when {
track.newChapters == 0 -> 0 track.newChapters == 0 -> 0
@ -182,8 +169,9 @@ class TrackingRepository @Inject constructor(
chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex
else -> track.newChapters else -> track.newChapters
}, },
lastCheck = System.currentTimeMillis(), lastCheckTime = System.currentTimeMillis(),
lastNotifiedChapterId = lastChapterId, lastChapterDate = maxOf(track.lastChapterDate, chapters.lastOrNull()?.uploadDate ?: 0L),
lastResult = track.lastResult,
) )
db.getTracksDao().upsert(entity) db.getTracksDao().upsert(entity)
} }
@ -204,19 +192,36 @@ class TrackingRepository @Inject constructor(
} }
} }
suspend fun getAllHistoryManga(): List<Manga> { suspend fun updateTracks() = db.withTransaction {
return db.getHistoryDao().findAllManga().toMangaList() val dao = db.getTracksDao()
dao.gc()
val ids = dao.findAllIds().toMutableSet()
val size = ids.size
// history
val historyIds = db.getHistoryDao().findAllIds()
for (mangaId in historyIds) {
if (!ids.remove(mangaId)) {
dao.upsert(TrackEntity.create(mangaId))
}
}
// favourites
val favoritesIds = db.getFavouritesDao().findIdsWithTrack()
for (mangaId in favoritesIds) {
if (!ids.remove(mangaId)) {
dao.upsert(TrackEntity.create(mangaId))
}
}
// remove unused
for (mangaId in ids) {
dao.delete(mangaId)
}
size - ids.size
} }
private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.getTracksDao().find(mangaId) ?: TrackEntity( return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId)
mangaId = mangaId,
totalChapters = 0,
lastChapterId = 0L,
newChapters = 0,
lastCheck = 0L,
lastNotifiedChapterId = 0L,
)
} }
private suspend fun updatePercent(updates: MangaUpdates.Success) { private suspend fun updatePercent(updates: MangaUpdates.Success) {
@ -234,17 +239,28 @@ class TrackingRepository @Inject constructor(
db.getHistoryDao().update(history.copy(percent = newPercent)) db.getHistoryDao().update(history.copy(percent = newPercent))
} }
private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity { private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity {
val chapters = updates.manga.chapters.orEmpty() val chapters = updates.manga.chapters.orEmpty()
return TrackEntity( return when (updates) {
is MangaUpdates.Failure -> TrackEntity(
mangaId = mangaId,
lastChapterId = lastChapterId,
newChapters = newChapters,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapterDate,
lastResult = TrackEntity.RESULT_FAILED,
)
is MangaUpdates.Success -> TrackEntity(
mangaId = mangaId, mangaId = mangaId,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: NO_ID, lastChapterId = chapters.lastOrNull()?.id ?: NO_ID,
newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0,
lastCheck = System.currentTimeMillis(), lastCheckTime = System.currentTimeMillis(),
lastNotifiedChapterId = NO_ID, lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate },
lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE,
) )
} }
}
private suspend fun gcIfNotCalled() { private suspend fun gcIfNotCalled() {
if (isGcCalled.compareAndSet(false, true)) { if (isGcCalled.compareAndSet(false, true)) {

@ -0,0 +1,244 @@
package org.xtimms.shirizu.sections.details
import android.net.Uri
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.AsyncImageImpl
import org.xtimms.shirizu.core.components.MangaCover
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
@Composable
fun ClassicDetailsInfoBox(
coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
onCoverClick: () -> Unit,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
onDownloadClick: () -> Unit,
) {
Box(modifier = modifier) {
// Backdrop
val backdropGradientColors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
AsyncImageImpl(
coil = coil,
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(colors = backdropGradientColors),
)
}
.blur(3.dp)
.alpha(0.33f),
)
// Manga & source info
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaInfoSmall(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onCoverClick = onCoverClick,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
} else {
MangaInfoLarge(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onCoverClick = onCoverClick,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
}
}
}
}
@Composable
fun MangaInfoLarge(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
source: MangaSource,
state: MangaState?,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onCoverClick: () -> Unit,
onSourceClicked: () -> Unit,
onDownloadClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
coil = coil,
modifier = Modifier
.fillMaxWidth(0.65f)
.clickable(
role = Role.Button,
onClick = onCoverClick
),
data = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source.title,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
}
}
@Composable
fun MangaInfoSmall(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onCoverClick: () -> Unit,
onSourceClicked: () -> Unit,
onDownloadClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = appBarPadding + 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImageImpl(
coil = coil,
modifier = Modifier
.padding(horizontal = 16.dp)
.sizeIn(maxWidth = 54.dp)
.align(Alignment.Start)
.aspectRatio(1f)
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = onCoverClick
),
model = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
)
DetailsContentInfo(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source.title,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
}
}

@ -15,33 +15,26 @@ import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
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.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LocalLibrary import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
@ -49,20 +42,16 @@ import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Upcoming import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip import androidx.compose.material3.InputChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -82,14 +71,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
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.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -105,10 +91,8 @@ import org.xtimms.shirizu.core.components.ButtonType
import org.xtimms.shirizu.core.components.HtmlTextField import org.xtimms.shirizu.core.components.HtmlTextField
import org.xtimms.shirizu.core.components.MangaCover import org.xtimms.shirizu.core.components.MangaCover
import org.xtimms.shirizu.core.components.ReadButton import org.xtimms.shirizu.core.components.ReadButton
import org.xtimms.shirizu.core.parser.favicon.faviconUri
import org.xtimms.shirizu.sections.details.data.ReadingTime import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo import org.xtimms.shirizu.sections.details.model.HistoryInfo
import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.utils.composable.clickableNoIndication import org.xtimms.shirizu.utils.composable.clickableNoIndication
import org.xtimms.shirizu.utils.composable.secondaryItemAlpha import org.xtimms.shirizu.utils.composable.secondaryItemAlpha
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -116,106 +100,7 @@ import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@Composable @Composable
fun DetailsInfoBox( fun MangaAndSourceTitlesLarge(
coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
onCoverClick: () -> Unit,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
onDownloadClick: () -> Unit,
) {
Column(modifier = modifier) {
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
AsyncImageImpl(
coil = coil,
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp)
.aspectRatio(1f)
.clickable(
role = Role.Button,
onClick = onCoverClick
)
.clip(MaterialTheme.shapes.large)
)
if (isNsfw) {
ElevatedAssistChip(
modifier = Modifier.padding(end = 32.dp, bottom = 8.dp),
onClick = { /*TODO*/ },
label = {
Text(
text = "18+",
color = MaterialTheme.colorScheme.onErrorContainer
)
},
border = BorderStroke(1.dp, MaterialTheme.colorScheme.errorContainer),
colors = AssistChipDefaults.elevatedAssistChipColors()
.copy(containerColor = MaterialTheme.colorScheme.errorContainer)
)
}
}
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaAndSourceTitlesSmall(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
} else {
MangaAndSourceTitlesLarge(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
}
}
}
}
@Composable
private fun MangaAndSourceTitlesLarge(
coil: ImageLoader, coil: ImageLoader,
appBarPadding: Dp, appBarPadding: Dp,
imageUrl: String, imageUrl: String,
@ -264,7 +149,7 @@ private fun MangaAndSourceTitlesLarge(
} }
@Composable @Composable
private fun MangaAndSourceTitlesSmall( fun MangaAndSourceTitlesSmall(
coil: ImageLoader, coil: ImageLoader,
favicon: Uri, favicon: Uri,
title: String, title: String,
@ -288,15 +173,6 @@ private fun MangaAndSourceTitlesSmall(
.padding(top = 8.dp), .padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
/*AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(PaddingValues(bottom = 8.dp))
.clip(RoundedCornerShape(100))
.size(48.dp),
)*/
DetailsContentInfo( DetailsContentInfo(
coil = coil, coil = coil,
favicon = favicon, favicon = favicon,
@ -321,7 +197,7 @@ private fun MangaAndSourceTitlesSmall(
ExperimentalMaterial3Api::class ExperimentalMaterial3Api::class
) )
@Composable @Composable
private fun DetailsContentInfo( fun DetailsContentInfo(
coil: ImageLoader, coil: ImageLoader,
favicon: Uri, favicon: Uri,
title: String, title: String,
@ -528,23 +404,6 @@ private fun DetailsContentInfo(
} }
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
} }
/*Row(modifier = Modifier
.weight(.5f)
.padding(start = 4.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AnimatedButton(
modifier = Modifier.size(54.dp),
type = KeyboardButtonType.PRIMARY,
icon = Icons.Outlined.FavoriteBorder
)
AnimatedButton(
modifier = Modifier
.height(54.dp)
.fillMaxWidth(),
type = KeyboardButtonType.TERTIARY,
icon = Icons.Outlined.PlayArrow
)
}*/
} }
} }
@ -621,7 +480,7 @@ fun ExpandableMangaDescription(
) { ) {
tags.forEach { tags.forEach {
TagsChip( TagsChip(
modifier = DefaultTagChipModifier, modifier = modifier.padding(vertical = 4.dp),
tag = it, tag = it,
onClick = { onClick = {
tagSelected = it.title tagSelected = it.title
@ -645,8 +504,6 @@ private fun MangaSummary(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uriHandler = LocalUriHandler.current
val animProgress by animateFloatAsState( val animProgress by animateFloatAsState(
targetValue = if (expanded) 1f else 0f, targetValue = if (expanded) 1f else 0f,
label = "summary", label = "summary",
@ -728,8 +585,6 @@ private fun MangaSummary(
} }
} }
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TagsChip( private fun TagsChip(

@ -15,8 +15,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
@ -62,12 +60,12 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.xtimms.shirizu.LocalWindowWidthState import org.xtimms.shirizu.LocalWindowWidthState
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.HapticFeedback.slightHapticFeedback import org.xtimms.shirizu.core.HapticFeedback.slightHapticFeedback
import org.xtimms.shirizu.core.components.DetailsToolbar import org.xtimms.shirizu.core.components.ClassicDetailsToolbar
import org.xtimms.shirizu.core.components.MangaHorizontalItem import org.xtimms.shirizu.core.components.MangaHorizontalItem
import org.xtimms.shirizu.core.components.ModernDetailsToolbar
import org.xtimms.shirizu.core.parser.favicon.faviconUri import org.xtimms.shirizu.core.parser.favicon.faviconUri
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.AppSettings.getBoolean import org.xtimms.shirizu.core.prefs.AppSettings.getBoolean
@ -115,6 +113,7 @@ fun DetailsView(
} }
} }
} else null } else null
val isModernView = AppSettings.isModernViewEnabled()
val checkNetworkOrDownload = { val checkNetworkOrDownload = {
if (!AppSettings.isNetworkAvailableForDownload()) { if (!AppSettings.isNetworkAvailableForDownload()) {
@ -224,13 +223,23 @@ fun DetailsView(
if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f, if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
label = "Top Bar Background", label = "Top Bar Background",
) )
DetailsToolbar( if (isModernView) {
ModernDetailsToolbar(
title = viewModel.details.value?.toManga()?.title.orEmpty(), title = viewModel.details.value?.toManga()?.title.orEmpty(),
titleAlphaProvider = { animatedTitleAlpha }, titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha }, backgroundAlphaProvider = { animatedBgAlpha },
navigateBack = { navigateBack() }, navigateBack = { navigateBack() },
navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) }, navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
) )
} else {
ClassicDetailsToolbar(
title = viewModel.details.value?.toManga()?.title.orEmpty(),
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
navigateBack = { navigateBack() },
navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
)
}
}, },
snackbarHost = { snackbarHost = {
SnackbarHost( SnackbarHost(
@ -245,7 +254,7 @@ fun DetailsView(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
state = chapterListState, state = chapterListState,
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = contentPadding.calculateTopPadding() - 60.dp, top = if (isModernView) contentPadding.calculateTopPadding() - 60.dp else 0.dp,
start = contentPadding.calculateStartPadding(layoutDirection), start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding(), bottom = contentPadding.calculateBottomPadding(),
@ -256,7 +265,8 @@ fun DetailsView(
key = DetailsViewItem.INFO_BOX, key = DetailsViewItem.INFO_BOX,
contentType = DetailsViewItem.INFO_BOX contentType = DetailsViewItem.INFO_BOX
) { ) {
DetailsInfoBox( if (isModernView) {
ModernDetailsInfoBox(
coil = coil, coil = coil,
imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(), imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
favicon = manga?.source?.faviconUri() ?: Uri.EMPTY, favicon = manga?.source?.faviconUri() ?: Uri.EMPTY,
@ -288,6 +298,40 @@ fun DetailsView(
}, },
onDownloadClick = downloadCallback onDownloadClick = downloadCallback
) )
} else {
ClassicDetailsInfoBox(
coil = coil,
imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
favicon = manga?.source?.faviconUri() ?: Uri.EMPTY,
title = manga?.title.orEmpty(),
altTitle = manga?.altTitle.orEmpty(),
author = manga?.author.orEmpty(),
isNsfw = manga?.isNsfw ?: true,
state = manga?.state ?: MangaState.FINISHED,
source = manga?.source ?: MangaSource.DUMMY,
isTabletUi = false,
appBarPadding = topPadding,
onCoverClick = {
navigateToFullImage(
arrayOf(
manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
).toNavArgument()
)
},
historyInfo = historyInfo,
readingTime = readingTime,
isInShelf = favouriteCategories,
onAddToShelfClicked = {
openCategoriesBottomSheet = !openCategoriesBottomSheet
},
onSourceClicked = {
navigateToSource(
manga?.source ?: MangaSource.DUMMY
)
},
onDownloadClick = downloadCallback
)
}
} }
item( item(

@ -0,0 +1,129 @@
package org.xtimms.shirizu.sections.details
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.ElevatedAssistChip
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.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.core.AsyncImageImpl
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
@Composable
fun ModernDetailsInfoBox(
coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
onCoverClick: () -> Unit,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
onDownloadClick: () -> Unit,
) {
Column(modifier = modifier) {
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
AsyncImageImpl(
coil = coil,
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp)
.aspectRatio(1f)
.clickable(
role = Role.Button,
onClick = onCoverClick
)
.clip(MaterialTheme.shapes.large)
)
if (isNsfw) {
ElevatedAssistChip(
modifier = Modifier.padding(end = 32.dp, bottom = 8.dp),
onClick = { /*TODO*/ },
label = {
Text(
text = "18+",
color = MaterialTheme.colorScheme.onErrorContainer
)
},
border = BorderStroke(1.dp, MaterialTheme.colorScheme.errorContainer),
colors = AssistChipDefaults.elevatedAssistChipColors()
.copy(containerColor = MaterialTheme.colorScheme.errorContainer)
)
}
}
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaAndSourceTitlesSmall(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
} else {
MangaAndSourceTitlesLarge(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
author = author,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
historyInfo = historyInfo,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
}
}
}
}

@ -45,7 +45,7 @@ import org.xtimms.shirizu.core.components.effects.RowEntity
import org.xtimms.shirizu.core.components.effects.RowEntityType import org.xtimms.shirizu.core.components.effects.RowEntityType
import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed
import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState
import org.xtimms.shirizu.core.screens.EmptyScreen import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.sections.feed.model.toFeedItem import org.xtimms.shirizu.sections.feed.model.toFeedItem
import org.xtimms.shirizu.utils.lang.calculateTimeAgo import org.xtimms.shirizu.utils.lang.calculateTimeAgo

@ -1,24 +1,17 @@
package org.xtimms.shirizu.sections.history package org.xtimms.shirizu.sections.history
//noinspection UsingMaterialAndMaterial3Libraries //noinspection UsingMaterialAndMaterial3Libraries
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DismissDirection import androidx.compose.material.DismissDirection
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
@ -43,14 +36,12 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min import androidx.compose.ui.unit.min
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader import coil.ImageLoader
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.collapsable
import org.xtimms.shirizu.core.components.ListGroupHeader import org.xtimms.shirizu.core.components.ListGroupHeader
import org.xtimms.shirizu.core.components.effects.RowEntity import org.xtimms.shirizu.core.components.effects.RowEntity
import org.xtimms.shirizu.core.components.effects.RowEntityType import org.xtimms.shirizu.core.components.effects.RowEntityType
@ -58,8 +49,8 @@ import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed
import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.SWIPE_TUTORIAL import org.xtimms.shirizu.core.prefs.SWIPE_TUTORIAL
import org.xtimms.shirizu.core.screens.EmptyScreen import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.core.screens.LoadingScreen import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.utils.lang.calculateTimeAgo import org.xtimms.shirizu.utils.lang.calculateTimeAgo
import org.xtimms.shirizu.utils.lang.isSameDay import org.xtimms.shirizu.utils.lang.isSameDay
import java.time.Instant import java.time.Instant

@ -34,7 +34,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.BackIconButton import org.xtimms.shirizu.core.components.BackIconButton
import org.xtimms.shirizu.core.screens.EmptyScreen import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.ui.theme.ShirizuTheme import org.xtimms.shirizu.ui.theme.ShirizuTheme
const val SEARCH_DESTINATION = "search" const val SEARCH_DESTINATION = "search"

@ -15,8 +15,11 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -30,6 +33,9 @@ import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -37,18 +43,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.BackIconButton
import org.xtimms.shirizu.core.components.PreferencesHintCard import org.xtimms.shirizu.core.components.PreferencesHintCard
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.components.SettingItem import org.xtimms.shirizu.core.components.SettingItem
import org.xtimms.shirizu.core.components.SettingTitle
import org.xtimms.shirizu.core.components.SmallTopAppBar
import org.xtimms.shirizu.utils.FileSize import org.xtimms.shirizu.utils.FileSize
const val SETTINGS_DESTINATION = "settings" const val SETTINGS_DESTINATION = "settings"
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("BatteryLife") @SuppressLint("BatteryLife")
@Composable @Composable
fun SettingsView( fun SettingsView(
@ -76,6 +86,8 @@ fun SettingsView(
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName)) mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val intent = val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}") data = Uri.parse("package:${context.packageName}")
@ -96,9 +108,22 @@ fun SettingsView(
showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName) showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName)
} }
ScaffoldWithTopAppBar( Scaffold(
title = stringResource(R.string.settings), modifier = Modifier
navigateBack = navigateBack .fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
SmallTopAppBar(
titleText = stringResource(id = R.string.settings),
navigationIcon = {
BackIconButton {
navigateBack()
}
},
scrollBehavior = scrollBehavior
)
},
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@ -106,6 +131,9 @@ fun SettingsView(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
) )
) { ) {
item {
SettingTitle(text = stringResource(id = R.string.settings))
}
item { item {
AnimatedVisibility( AnimatedVisibility(
visible = showBatteryHint && isActivityAvailable, visible = showBatteryHint && isActivityAvailable,

@ -31,8 +31,8 @@ import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ColorLens import androidx.compose.material.icons.outlined.ColorLens
import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LightMode import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -72,7 +72,7 @@ import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.OFF import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.ON import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.ON
import org.xtimms.shirizu.core.prefs.READING_TIME import org.xtimms.shirizu.core.prefs.MODERN_VIEW
import org.xtimms.shirizu.core.prefs.STYLE_MONOCHROME import org.xtimms.shirizu.core.prefs.STYLE_MONOCHROME
import org.xtimms.shirizu.core.prefs.STYLE_TONAL_SPOT import org.xtimms.shirizu.core.prefs.STYLE_TONAL_SPOT
import org.xtimms.shirizu.core.prefs.paletteStyles import org.xtimms.shirizu.core.prefs.paletteStyles
@ -85,7 +85,6 @@ import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.shirizu.ui.monet.a1 import org.xtimms.shirizu.ui.monet.a1
import org.xtimms.shirizu.ui.monet.a2 import org.xtimms.shirizu.ui.monet.a2
import org.xtimms.shirizu.ui.monet.a3 import org.xtimms.shirizu.ui.monet.a3
import org.xtimms.shirizu.utils.material.combineColors
import org.xtimms.shirizu.utils.system.toDisplayName import org.xtimms.shirizu.utils.system.toDisplayName
import java.util.Locale import java.util.Locale
@ -101,6 +100,10 @@ fun AppearanceView(
) { ) {
val localDensity = LocalDensity.current val localDensity = LocalDensity.current
var isModernViewEnabled by remember {
mutableStateOf(AppSettings.isModernViewEnabled())
}
ScaffoldWithTopAppBar( ScaffoldWithTopAppBar(
title = stringResource(R.string.appearance), title = stringResource(R.string.appearance),
navigateBack = navigateBack navigateBack = navigateBack
@ -246,6 +249,15 @@ fun AppearanceView(
icon = Icons.Outlined.Language, icon = Icons.Outlined.Language,
description = Locale.getDefault().toDisplayName(), description = Locale.getDefault().toDisplayName(),
onClick = { navigateToLanguages() }) onClick = { navigateToLanguages() })
PreferenceSubtitle(text = stringResource(id = R.string.user_interface))
PreferenceSwitch(
icon = Icons.Outlined.Layers,
title = stringResource(id = R.string.details_modern_look),
isChecked = isModernViewEnabled,
) {
isModernViewEnabled = !isModernViewEnabled
AppSettings.updateValue(MODERN_VIEW, isModernViewEnabled)
}
} }
} }
} }
@ -286,8 +298,12 @@ fun RowScope.ColorButtonImpl(
onClick: () -> Unit = {} onClick: () -> Unit = {}
) { ) {
val containerSize by animateDpAsState(targetValue = if (isSelected.invoke()) 28.dp else 0.dp) val containerSize by animateDpAsState(targetValue = if (isSelected.invoke()) 28.dp else 0.dp,
val iconSize by animateDpAsState(targetValue = if (isSelected.invoke()) 16.dp else 0.dp) label = "containerSize"
)
val iconSize by animateDpAsState(targetValue = if (isSelected.invoke()) 16.dp else 0.dp,
label = "iconSize"
)
Surface( Surface(
modifier = modifier modifier = modifier

@ -66,6 +66,7 @@ import org.xtimms.shirizu.utils.MaskVisualTransformation
import org.xtimms.shirizu.utils.NumberDefaults.INPUT_LENGTH import org.xtimms.shirizu.utils.NumberDefaults.INPUT_LENGTH
import org.xtimms.shirizu.utils.NumberDefaults.MASK import org.xtimms.shirizu.utils.NumberDefaults.MASK
import org.xtimms.shirizu.utils.NumberDefaults.MAX_PORT import org.xtimms.shirizu.utils.NumberDefaults.MAX_PORT
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
import org.xtimms.shirizu.utils.lang.intState import org.xtimms.shirizu.utils.lang.intState
import java.net.Proxy import java.net.Proxy
@ -175,14 +176,14 @@ fun NetworkView(
PreferenceItem( PreferenceItem(
enabled = proxy != Proxy.Type.DIRECT.ordinal, enabled = proxy != Proxy.Type.DIRECT.ordinal,
title = stringResource(id = R.string.proxy_address), title = stringResource(id = R.string.proxy_address),
description = address, description = address.ifNullOrEmpty { stringResource(id = R.string.not_set) },
) { showProxyAddressDialog = true } ) { showProxyAddressDialog = true }
} }
item { item {
PreferenceItem( PreferenceItem(
enabled = proxy != Proxy.Type.DIRECT.ordinal, enabled = proxy != Proxy.Type.DIRECT.ordinal,
title = stringResource(id = R.string.proxy_port), title = stringResource(id = R.string.proxy_port),
description = port.toString() description = if (port == 0) stringResource(id = R.string.not_set) else port.toString()
) { showProxyPortDialog = true } ) { showProxyPortDialog = true }
} }
item { item {
@ -192,14 +193,14 @@ fun NetworkView(
PreferenceItem( PreferenceItem(
enabled = proxy != Proxy.Type.DIRECT.ordinal, enabled = proxy != Proxy.Type.DIRECT.ordinal,
title = stringResource(id = R.string.proxy_username), title = stringResource(id = R.string.proxy_username),
description = username, description = username.ifNullOrEmpty { stringResource(id = R.string.not_set) },
) { showProxyUsernameDialog = true } ) { showProxyUsernameDialog = true }
} }
item { item {
PreferenceItem( PreferenceItem(
enabled = proxy != Proxy.Type.DIRECT.ordinal, enabled = proxy != Proxy.Type.DIRECT.ordinal,
title = stringResource(id = R.string.proxy_password), title = stringResource(id = R.string.proxy_password),
description = String(CharArray(password.length) { '\u2022' }), description = String(CharArray(password.length) { '\u2022' }).ifNullOrEmpty { stringResource(id = R.string.not_set) },
) { showProxyPasswordDialog = true } ) { showProxyPasswordDialog = true }
} }
} }

@ -14,7 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Category import androidx.compose.material.icons.outlined.Category
import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -39,13 +39,13 @@ import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.PreferenceItem import org.xtimms.shirizu.core.components.PreferenceItem
import org.xtimms.shirizu.core.components.PreferenceSubtitle import org.xtimms.shirizu.core.components.PreferenceSubtitle
import org.xtimms.shirizu.core.components.PreferenceSwitch import org.xtimms.shirizu.core.components.PreferenceSwitch
import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.AppSettings.getInt import org.xtimms.shirizu.core.prefs.AppSettings.getInt
import org.xtimms.shirizu.core.prefs.AppSettings.getString
import org.xtimms.shirizu.core.prefs.GRID_COLUMNS import org.xtimms.shirizu.core.prefs.GRID_COLUMNS
import org.xtimms.shirizu.core.prefs.PROXY_ADDRESS
import org.xtimms.shirizu.core.prefs.TABS_MANGA_COUNT import org.xtimms.shirizu.core.prefs.TABS_MANGA_COUNT
import org.xtimms.shirizu.core.prefs.TRACKER
import org.xtimms.shirizu.sections.shelf.ShelfViewModel import org.xtimms.shirizu.sections.shelf.ShelfViewModel
const val SHELF_SETTINGS_DESTINATION = "shelf_settings" const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
@ -54,9 +54,11 @@ const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
fun ShelfSettingsView( fun ShelfSettingsView(
shelfViewModel: ShelfViewModel = hiltViewModel(), shelfViewModel: ShelfViewModel = hiltViewModel(),
navigateBack: () -> Unit, navigateBack: () -> Unit,
navigateToCategories: () -> Unit navigateToCategories: () -> Unit,
navigateToTrackerSettings: () -> Unit = {}
) { ) {
var isTrackerEnabled by remember { mutableStateOf(AppSettings.isTrackerEnabled()) }
var showGridColumnsDialog by remember { mutableStateOf(false) } var showGridColumnsDialog by remember { mutableStateOf(false) }
val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList()) val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
@ -106,7 +108,10 @@ fun ShelfSettingsView(
item { item {
PreferenceItem( PreferenceItem(
title = stringResource(id = R.string.grid_columns_count), title = stringResource(id = R.string.grid_columns_count),
description = stringResource(id = R.string.grid_columns_count_desc, gridColumns), description = stringResource(
id = R.string.grid_columns_count_desc,
gridColumns
),
icon = Icons.Outlined.GridView icon = Icons.Outlined.GridView
) { showGridColumnsDialog = true } ) { showGridColumnsDialog = true }
} }
@ -114,10 +119,17 @@ fun ShelfSettingsView(
PreferenceSubtitle(text = stringResource(id = R.string.updates)) PreferenceSubtitle(text = stringResource(id = R.string.updates))
} }
item { item {
PreferenceItem( PreferenceSwitchWithDivider(
title = stringResource(id = R.string.auto_update), title = stringResource(id = R.string.auto_update),
description = "Off", description = if (isTrackerEnabled) stringResource(id = R.string.on)
icon = Icons.Outlined.Update else stringResource(id = R.string.off),
icon = Icons.Outlined.RssFeed,
isChecked = isTrackerEnabled,
onClick = navigateToTrackerSettings,
onChecked = {
isTrackerEnabled = !isTrackerEnabled
AppSettings.updateValue(TRACKER, isTrackerEnabled)
}
) )
} }
} }

@ -3,41 +3,40 @@ package org.xtimms.shirizu.sections.settings.sources.catalog
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.AsyncImageImpl import org.xtimms.shirizu.core.AsyncImageImpl
import org.xtimms.shirizu.core.parser.favicon.faviconUri import org.xtimms.shirizu.core.parser.favicon.faviconUri
import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable @Composable
fun SourceCatalogItem( fun SourceCatalogItem(
coil: ImageLoader, coil: ImageLoader,
source: MangaSource, source: MangaSource,
modifier: Modifier = Modifier,
) { ) {
Card(
modifier = modifier,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(8.dp),
start = 16.dp,
top = 16.dp,
end = 16.dp,
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImageImpl( AsyncImageImpl(
modifier = Modifier.size(42.dp),
coil = coil, coil = coil,
contentDescription = null, contentDescription = null,
model = source.faviconUri() model = source.faviconUri()
@ -45,8 +44,19 @@ fun SourceCatalogItem(
Text( Text(
text = source.title, text = source.title,
modifier = Modifier modifier = Modifier
.padding(start = 16.dp), .padding(start = 16.dp)
.weight(1f),
) )
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Outlined.Add, contentDescription = null)
}
}
} }
@Preview
@Composable
fun SourceCatalogItemPreview() {
ShirizuTheme {
SourceCatalogItem(coil = ImageLoader(LocalContext.current), source = MangaSource.MANGADEX)
} }
} }

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
@ -17,14 +16,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import coil.ImageLoader import coil.ImageLoader
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.MangaGridItem import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.core.screens.EmptyScreen
import org.xtimms.shirizu.sections.shelf.LazyShelfGrid
import org.xtimms.shirizu.sections.shelf.ShelfGrid
import org.xtimms.shirizu.sections.shelf.ShelfManga
import org.xtimms.shirizu.utils.system.plus import org.xtimms.shirizu.utils.system.plus
@Composable @Composable

@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import coil.ImageLoader import coil.ImageLoader
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.screens.EmptyScreen import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.utils.system.plus import org.xtimms.shirizu.utils.system.plus
@Composable @Composable

@ -69,6 +69,8 @@ fun LocalDateTime.toDate(): Date = Date(this.toEpochSecond(
ZoneId.systemDefault().rules.getOffset(this) ZoneId.systemDefault().rules.getOffset(this)
) * 1000) ) * 1000)
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
sealed class DateTimeAgo { sealed class DateTimeAgo {
abstract fun format(resources: Resources): String abstract fun format(resources: Resources): String

@ -21,5 +21,9 @@ inline val String.intState
mutableIntStateOf(this.getInt()) mutableIntStateOf(this.getInt())
} }
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
// clamp(3.5f, 6.7f) > [0.0f, 1.0f] // clamp(3.5f, 6.7f) > [0.0f, 1.0f]
fun Float.clamp(min: Float, max: Float): Float = (1f - ((this.coerceIn(min, max) - min) / (max - min))) fun Float.clamp(min: Float, max: Float): Float = (1f - ((this.coerceIn(min, max) - min) / (max - min)))

@ -17,17 +17,11 @@ class WorkScheduleManager @Inject constructor(
fun init() { fun init() {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, isEnabled = true, force = false) // TODO updateWorkerImpl(trackerScheduler, AppSettings.isTrackerEnabled(), force = true)
updateWorkerImpl(suggestionScheduler, AppSettings.isSuggestionsEnabled(), force = false) updateWorkerImpl(suggestionScheduler, AppSettings.isSuggestionsEnabled(), force = false)
} }
} }
private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) {
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(scheduler, isEnabled, force)
}
}
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) {
if (force || scheduler.isScheduled() != isEnabled) { if (force || scheduler.isScheduled() != isEnabled) {
if (isEnabled) { if (isEnabled) {

@ -8,7 +8,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.edit
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
@ -31,22 +30,21 @@ import dagger.Reusable
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.exceptions.CloudflareProtectedException import org.xtimms.shirizu.core.exceptions.CloudflareProtectedException
import org.xtimms.shirizu.core.logs.FileLogger import org.xtimms.shirizu.core.logs.FileLogger
import org.xtimms.shirizu.core.logs.TrackerLogger import org.xtimms.shirizu.core.logs.TrackerLogger
@ -61,6 +59,7 @@ import org.xtimms.shirizu.utils.system.trySetForeground
import org.xtimms.shirizu.work.PeriodicWorkScheduler import org.xtimms.shirizu.work.PeriodicWorkScheduler
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
@HiltWorker @HiltWorker
class TrackWorker @AssistedInject constructor( class TrackWorker @AssistedInject constructor(
@ -75,10 +74,10 @@ class TrackWorker @AssistedInject constructor(
private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
trySetForeground() val isForeground = trySetForeground()
logger.log("doWork(): attempt $runAttemptCount") logger.log("doWork(): attempt $runAttemptCount")
return try { return try {
doWorkImpl() doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
@ -92,49 +91,18 @@ class TrackWorker @AssistedInject constructor(
} }
} }
private suspend fun doWorkImpl(): Result { private suspend fun doWorkImpl(isFullRun: Boolean): Result {
if (!AppSettings.isTrackerEnabled()) { if (!AppSettings.isTrackerEnabled()) {
return Result.success(workDataOf(0, 0)) return Result.success(workDataOf(0, 0))
} }
val retryIds = getRetryIds() val tracks = tracker.getTracks(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE)
val tracks = if (retryIds.isNotEmpty()) {
tracker.getTracks(retryIds)
} else {
tracker.getAllTracks()
}
logger.log("Total ${tracks.size} tracks") logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
return Result.success(workDataOf(0, 0)) return Result.success(workDataOf(0, 0))
} }
val results = checkUpdatesAsync(tracks) checkUpdatesAsync(tracks)
tracker.gc() return Result.success()
var success = 0
var failed = 0
val retry = HashSet<Long>()
results.forEach { x ->
when (x) {
is MangaUpdates.Success -> success++
is MangaUpdates.Failure -> {
failed++
if (x.shouldRetry()) {
retry += x.manga.id
}
}
}
}
if (runAttemptCount > MAX_ATTEMPTS) {
retry.clear()
}
setRetryIds(retry)
logger.log("Result: success: $success, failed: $failed, retry: ${retry.size}")
val resultData = workDataOf(success, failed)
return when {
retry.isNotEmpty() -> Result.retry()
success == 0 && failed != 0 -> Result.failure(resultData)
else -> Result.success(resultData)
}
} }
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates> { private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates> {
@ -145,10 +113,13 @@ class TrackWorker @AssistedInject constructor(
semaphore.withPermit { semaphore.withPermit {
send( send(
runCatchingCancellable { runCatchingCancellable {
tracker.fetchUpdates(track, commit = true) tracker.fetchUpdates(track, commit = true).let {
.copy(channelId = channelId) if (it is MangaUpdates.Success) {
}.onFailure { e -> it.copy(channelId = channelId)
logger.log("checkUpdatesAsync", e) } else {
it
}
}
}.getOrElse { error -> }.getOrElse { error ->
MangaUpdates.Failure( MangaUpdates.Failure(
manga = track.manga, manga = track.manga,
@ -166,6 +137,7 @@ class TrackWorker @AssistedInject constructor(
when (it) { when (it) {
is MangaUpdates.Failure -> { is MangaUpdates.Failure -> {
val e = it.error val e = it.error
logger.log("checkUpdatesAsync", e)
if (e is CloudflareProtectedException) { if (e is CloudflareProtectedException) {
e.printStackTrace() e.printStackTrace()
} }
@ -281,22 +253,6 @@ class TrackWorker @AssistedInject constructor(
} }
}.build() }.build()
private suspend fun setRetryIds(ids: Set<Long>) = runInterruptible(Dispatchers.IO) {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
prefs.edit(commit = true) {
if (ids.isEmpty()) {
remove(KEY_RETRY_IDS)
} else {
putStringSet(KEY_RETRY_IDS, ids.mapToSet { it.toString() })
}
}
}
private fun getRetryIds(): Set<Long> {
val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE)
return prefs.getStringSet(KEY_RETRY_IDS, null)?.mapToSet { it.toLong() }.orEmpty()
}
private fun workDataOf(success: Int, failed: Int): Data { private fun workDataOf(success: Int, failed: Int): Data {
return Data.Builder() return Data.Builder()
.putInt(DATA_KEY_SUCCESS, success) .putInt(DATA_KEY_SUCCESS, success)
@ -307,11 +263,15 @@ class TrackWorker @AssistedInject constructor(
@Reusable @Reusable
class Scheduler @Inject constructor( class Scheduler @Inject constructor(
private val workManager: WorkManager, private val workManager: WorkManager,
private val dbProvider: Provider<ShirizuDatabase>,
) : PeriodicWorkScheduler { ) : PeriodicWorkScheduler {
override suspend fun schedule() { override suspend fun schedule() {
val constraints = createConstraints() val constraints = createConstraints()
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS) val runCount = dbProvider.get().getTracksDao().getTracksCount()
val runsPerFullCheck = (runCount / BATCH_SIZE.toFloat()).toIntUp()
val interval = (6 / runsPerFullCheck).coerceAtLeast(2)
val request = PeriodicWorkRequestBuilder<TrackWorker>(interval.toLong(), TimeUnit.HOURS)
.setConstraints(constraints) .setConstraints(constraints)
.addTag(TAG) .addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
@ -363,10 +323,9 @@ class TrackWorker @AssistedInject constructor(
const val WORKER_NOTIFICATION_ID = 35 const val WORKER_NOTIFICATION_ID = 35
const val TAG = "tracking" const val TAG = "tracking"
const val TAG_ONESHOT = "tracking_oneshot" const val TAG_ONESHOT = "tracking_oneshot"
const val MAX_PARALLELISM = 3 const val MAX_PARALLELISM = 6
const val MAX_ATTEMPTS = 3
const val DATA_KEY_SUCCESS = "success" const val DATA_KEY_SUCCESS = "success"
const val DATA_KEY_FAILED = "failed" const val DATA_KEY_FAILED = "failed"
const val KEY_RETRY_IDS = "retry" const val BATCH_SIZE = 20
} }
} }

@ -306,4 +306,6 @@
<string name="open_menu">Open menu</string> <string name="open_menu">Open menu</string>
<string name="crash_screen_something_went_wrong">My bad…</string> <string name="crash_screen_something_went_wrong">My bad…</string>
<string name="crash_screen_toast">Logs about this bug have been sent to the developers, thank you.</string> <string name="crash_screen_toast">Logs about this bug have been sent to the developers, thank you.</string>
<string name="user_interface">User interface</string>
<string name="details_modern_look">Modern look of the manga information screen</string>
</resources> </resources>
Loading…
Cancel
Save