mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 06:17:57 +01:00 
			
		
		
		
	Revert "Revert history Compose/SQLDelight changes"
This reverts commit 96c894ce5b.
			
			
This commit is contained in:
		
							
								
								
									
										94
									
								
								app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| package eu.kanade.data | ||||
|  | ||||
| import androidx.paging.PagingSource | ||||
| import com.squareup.sqldelight.Query | ||||
| import com.squareup.sqldelight.Transacter | ||||
| import com.squareup.sqldelight.android.paging3.QueryPagingSource | ||||
| import com.squareup.sqldelight.db.SqlDriver | ||||
| import com.squareup.sqldelight.runtime.coroutines.asFlow | ||||
| import com.squareup.sqldelight.runtime.coroutines.mapToList | ||||
| import com.squareup.sqldelight.runtime.coroutines.mapToOne | ||||
| import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull | ||||
| import eu.kanade.tachiyomi.Database | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.withContext | ||||
|  | ||||
| class AndroidDatabaseHandler( | ||||
|     val db: Database, | ||||
|     private val driver: SqlDriver, | ||||
|     val queryDispatcher: CoroutineDispatcher = Dispatchers.IO, | ||||
|     val transactionDispatcher: CoroutineDispatcher = queryDispatcher | ||||
| ) : DatabaseHandler { | ||||
|  | ||||
|     val suspendingTransactionId = ThreadLocal<Int>() | ||||
|  | ||||
|     override suspend fun <T> await(inTransaction: Boolean, block: suspend Database.() -> T): T { | ||||
|         return dispatch(inTransaction, block) | ||||
|     } | ||||
|  | ||||
|     override suspend fun <T : Any> awaitList( | ||||
|         inTransaction: Boolean, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): List<T> { | ||||
|         return dispatch(inTransaction) { block(db).executeAsList() } | ||||
|     } | ||||
|  | ||||
|     override suspend fun <T : Any> awaitOne( | ||||
|         inTransaction: Boolean, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): T { | ||||
|         return dispatch(inTransaction) { block(db).executeAsOne() } | ||||
|     } | ||||
|  | ||||
|     override suspend fun <T : Any> awaitOneOrNull( | ||||
|         inTransaction: Boolean, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): T? { | ||||
|         return dispatch(inTransaction) { block(db).executeAsOneOrNull() } | ||||
|     } | ||||
|  | ||||
|     override fun <T : Any> subscribeToList(block: Database.() -> Query<T>): Flow<List<T>> { | ||||
|         return block(db).asFlow().mapToList(queryDispatcher) | ||||
|     } | ||||
|  | ||||
|     override fun <T : Any> subscribeToOne(block: Database.() -> Query<T>): Flow<T> { | ||||
|         return block(db).asFlow().mapToOne(queryDispatcher) | ||||
|     } | ||||
|  | ||||
|     override fun <T : Any> subscribeToOneOrNull(block: Database.() -> Query<T>): Flow<T?> { | ||||
|         return block(db).asFlow().mapToOneOrNull(queryDispatcher) | ||||
|     } | ||||
|  | ||||
|     override fun <T : Any> subscribeToPagingSource( | ||||
|         countQuery: Database.() -> Query<Long>, | ||||
|         transacter: Database.() -> Transacter, | ||||
|         queryProvider: Database.(Long, Long) -> Query<T> | ||||
|     ): PagingSource<Long, T> { | ||||
|         return QueryPagingSource( | ||||
|             countQuery = countQuery(db), | ||||
|             transacter = transacter(db), | ||||
|             dispatcher = queryDispatcher, | ||||
|             queryProvider = { limit, offset -> | ||||
|                 queryProvider.invoke(db, limit, offset) | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private suspend fun <T> dispatch(inTransaction: Boolean, block: suspend Database.() -> T): T { | ||||
|         // Create a transaction if needed and run the calling block inside it. | ||||
|         if (inTransaction) { | ||||
|             return withTransaction { block(db) } | ||||
|         } | ||||
|  | ||||
|         // If we're currently in the transaction thread, there's no need to dispatch our query. | ||||
|         if (driver.currentTransaction() != null) { | ||||
|             return block(db) | ||||
|         } | ||||
|  | ||||
|         // Get the current database context and run the calling block. | ||||
|         val context = getCurrentDatabaseContext() | ||||
|         return withContext(context) { block(db) } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								app/src/main/java/eu/kanade/data/DatabaseAdapter.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/src/main/java/eu/kanade/data/DatabaseAdapter.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| package eu.kanade.data | ||||
|  | ||||
| import com.squareup.sqldelight.ColumnAdapter | ||||
| import java.util.* | ||||
|  | ||||
| val dateAdapter = object : ColumnAdapter<Date, Long> { | ||||
|     override fun decode(databaseValue: Long): Date = Date(databaseValue) | ||||
|     override fun encode(value: Date): Long = value.time | ||||
| } | ||||
|  | ||||
| private const val listOfStringsSeparator = ", " | ||||
| val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> { | ||||
|     override fun decode(databaseValue: String) = | ||||
|         if (databaseValue.isEmpty()) { | ||||
|             listOf() | ||||
|         } else { | ||||
|             databaseValue.split(listOfStringsSeparator) | ||||
|         } | ||||
|     override fun encode(value: List<String>) = value.joinToString(separator = listOfStringsSeparator) | ||||
| } | ||||
							
								
								
									
										39
									
								
								app/src/main/java/eu/kanade/data/DatabaseHandler.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/src/main/java/eu/kanade/data/DatabaseHandler.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| package eu.kanade.data | ||||
|  | ||||
| import androidx.paging.PagingSource | ||||
| import com.squareup.sqldelight.Query | ||||
| import com.squareup.sqldelight.Transacter | ||||
| import eu.kanade.tachiyomi.Database | ||||
| import kotlinx.coroutines.flow.Flow | ||||
|  | ||||
| interface DatabaseHandler { | ||||
|  | ||||
|     suspend fun <T> await(inTransaction: Boolean = false, block: suspend Database.() -> T): T | ||||
|  | ||||
|     suspend fun <T : Any> awaitList( | ||||
|         inTransaction: Boolean = false, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): List<T> | ||||
|  | ||||
|     suspend fun <T : Any> awaitOne( | ||||
|         inTransaction: Boolean = false, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): T | ||||
|  | ||||
|     suspend fun <T : Any> awaitOneOrNull( | ||||
|         inTransaction: Boolean = false, | ||||
|         block: suspend Database.() -> Query<T> | ||||
|     ): T? | ||||
|  | ||||
|     fun <T : Any> subscribeToList(block: Database.() -> Query<T>): Flow<List<T>> | ||||
|  | ||||
|     fun <T : Any> subscribeToOne(block: Database.() -> Query<T>): Flow<T> | ||||
|  | ||||
|     fun <T : Any> subscribeToOneOrNull(block: Database.() -> Query<T>): Flow<T?> | ||||
|  | ||||
|     fun <T : Any> subscribeToPagingSource( | ||||
|         countQuery: Database.() -> Query<Long>, | ||||
|         transacter: Database.() -> Transacter, | ||||
|         queryProvider: Database.(Long, Long) -> Query<T> | ||||
|     ): PagingSource<Long, T> | ||||
| } | ||||
							
								
								
									
										160
									
								
								app/src/main/java/eu/kanade/data/TransactionContext.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								app/src/main/java/eu/kanade/data/TransactionContext.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| package eu.kanade.data | ||||
|  | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.asContextElement | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||||
| import kotlinx.coroutines.withContext | ||||
| import java.util.concurrent.RejectedExecutionException | ||||
| import java.util.concurrent.atomic.AtomicInteger | ||||
| import kotlin.coroutines.ContinuationInterceptor | ||||
| import kotlin.coroutines.CoroutineContext | ||||
| import kotlin.coroutines.EmptyCoroutineContext | ||||
| import kotlin.coroutines.coroutineContext | ||||
| import kotlin.coroutines.resume | ||||
|  | ||||
| /** | ||||
|  * Returns the transaction dispatcher if we are on a transaction, or the database dispatchers. | ||||
|  */ | ||||
| internal suspend fun AndroidDatabaseHandler.getCurrentDatabaseContext(): CoroutineContext { | ||||
|     return coroutineContext[TransactionElement]?.transactionDispatcher ?: queryDispatcher | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Calls the specified suspending [block] in a database transaction. The transaction will be | ||||
|  * marked as successful unless an exception is thrown in the suspending [block] or the coroutine | ||||
|  * is cancelled. | ||||
|  * | ||||
|  * SQLDelight will only perform at most one transaction at a time, additional transactions are queued | ||||
|  * and executed on a first come, first serve order. | ||||
|  * | ||||
|  * Performing blocking database operations is not permitted in a coroutine scope other than the | ||||
|  * one received by the suspending block. It is recommended that all [Dao] function invoked within | ||||
|  * the [block] be suspending functions. | ||||
|  * | ||||
|  * The dispatcher used to execute the given [block] will utilize threads from SQLDelight's query executor. | ||||
|  */ | ||||
| internal suspend fun <T> AndroidDatabaseHandler.withTransaction(block: suspend () -> T): T { | ||||
|     // Use inherited transaction context if available, this allows nested suspending transactions. | ||||
|     val transactionContext = | ||||
|         coroutineContext[TransactionElement]?.transactionDispatcher ?: createTransactionContext() | ||||
|     return withContext(transactionContext) { | ||||
|         val transactionElement = coroutineContext[TransactionElement]!! | ||||
|         transactionElement.acquire() | ||||
|         try { | ||||
|             db.transactionWithResult { | ||||
|                 runBlocking(transactionContext) { | ||||
|                     block() | ||||
|                 } | ||||
|             } | ||||
|         } finally { | ||||
|             transactionElement.release() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Creates a [CoroutineContext] for performing database operations within a coroutine transaction. | ||||
|  * | ||||
|  * The context is a combination of a dispatcher, a [TransactionElement] and a thread local element. | ||||
|  * | ||||
|  * * The dispatcher will dispatch coroutines to a single thread that is taken over from the SQLDelight | ||||
|  * query executor. If the coroutine context is switched, suspending DAO functions will be able to | ||||
|  * dispatch to the transaction thread. | ||||
|  * | ||||
|  * * The [TransactionElement] serves as an indicator for inherited context, meaning, if there is a | ||||
|  * switch of context, suspending DAO methods will be able to use the indicator to dispatch the | ||||
|  * database operation to the transaction thread. | ||||
|  * | ||||
|  * * The thread local element serves as a second indicator and marks threads that are used to | ||||
|  * execute coroutines within the coroutine transaction, more specifically it allows us to identify | ||||
|  * if a blocking DAO method is invoked within the transaction coroutine. Never assign meaning to | ||||
|  * this value, for now all we care is if its present or not. | ||||
|  */ | ||||
| private suspend fun AndroidDatabaseHandler.createTransactionContext(): CoroutineContext { | ||||
|     val controlJob = Job() | ||||
|     // make sure to tie the control job to this context to avoid blocking the transaction if | ||||
|     // context get cancelled before we can even start using this job. Otherwise, the acquired | ||||
|     // transaction thread will forever wait for the controlJob to be cancelled. | ||||
|     // see b/148181325 | ||||
|     coroutineContext[Job]?.invokeOnCompletion { | ||||
|         controlJob.cancel() | ||||
|     } | ||||
|  | ||||
|     val dispatcher = transactionDispatcher.acquireTransactionThread(controlJob) | ||||
|     val transactionElement = TransactionElement(controlJob, dispatcher) | ||||
|     val threadLocalElement = | ||||
|         suspendingTransactionId.asContextElement(System.identityHashCode(controlJob)) | ||||
|     return dispatcher + transactionElement + threadLocalElement | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Acquires a thread from the executor and returns a [ContinuationInterceptor] to dispatch | ||||
|  * coroutines to the acquired thread. The [controlJob] is used to control the release of the | ||||
|  * thread by cancelling the job. | ||||
|  */ | ||||
| private suspend fun CoroutineDispatcher.acquireTransactionThread( | ||||
|     controlJob: Job | ||||
| ): ContinuationInterceptor { | ||||
|     return suspendCancellableCoroutine { continuation -> | ||||
|         continuation.invokeOnCancellation { | ||||
|             // We got cancelled while waiting to acquire a thread, we can't stop our attempt to | ||||
|             // acquire a thread, but we can cancel the controlling job so once it gets acquired it | ||||
|             // is quickly released. | ||||
|             controlJob.cancel() | ||||
|         } | ||||
|         try { | ||||
|             dispatch(EmptyCoroutineContext) { | ||||
|                 runBlocking { | ||||
|                     // Thread acquired, resume coroutine. | ||||
|                     continuation.resume(coroutineContext[ContinuationInterceptor]!!) | ||||
|                     controlJob.join() | ||||
|                 } | ||||
|             } | ||||
|         } catch (ex: RejectedExecutionException) { | ||||
|             // Couldn't acquire a thread, cancel coroutine. | ||||
|             continuation.cancel( | ||||
|                 IllegalStateException( | ||||
|                     "Unable to acquire a thread to perform the database transaction.", ex | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * A [CoroutineContext.Element] that indicates there is an on-going database transaction. | ||||
|  */ | ||||
| private class TransactionElement( | ||||
|     private val transactionThreadControlJob: Job, | ||||
|     val transactionDispatcher: ContinuationInterceptor | ||||
| ) : CoroutineContext.Element { | ||||
|  | ||||
|     companion object Key : CoroutineContext.Key<TransactionElement> | ||||
|  | ||||
|     override val key: CoroutineContext.Key<TransactionElement> | ||||
|         get() = TransactionElement | ||||
|  | ||||
|     /** | ||||
|      * Number of transactions (including nested ones) started with this element. | ||||
|      * Call [acquire] to increase the count and [release] to decrease it. If the count reaches zero | ||||
|      * when [release] is invoked then the transaction job is cancelled and the transaction thread | ||||
|      * is released. | ||||
|      */ | ||||
|     private val referenceCount = AtomicInteger(0) | ||||
|  | ||||
|     fun acquire() { | ||||
|         referenceCount.incrementAndGet() | ||||
|     } | ||||
|  | ||||
|     fun release() { | ||||
|         val count = referenceCount.decrementAndGet() | ||||
|         if (count < 0) { | ||||
|             throw IllegalStateException("Transaction was never started or was already released.") | ||||
|         } else if (count == 0) { | ||||
|             // Cancel the job that controls the transaction thread, causing it to be released. | ||||
|             transactionThreadControlJob.cancel() | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| package eu.kanade.data.chapter | ||||
|  | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
|  | ||||
| val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long) -> Chapter = | ||||
|     { id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload -> | ||||
|         Chapter( | ||||
|             id = id, | ||||
|             mangaId = mangaId, | ||||
|             read = read, | ||||
|             bookmark = bookmark, | ||||
|             lastPageRead = lastPageRead, | ||||
|             dateFetch = dateFetch, | ||||
|             sourceOrder = sourceOrder, | ||||
|             url = url, | ||||
|             name = name, | ||||
|             dateUpload = dateUpload, | ||||
|             chapterNumber = chapterNumber, | ||||
|             scanlator = scanlator, | ||||
|         ) | ||||
|     } | ||||
							
								
								
									
										26
									
								
								app/src/main/java/eu/kanade/data/history/HistoryMapper.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/src/main/java/eu/kanade/data/history/HistoryMapper.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.data.history | ||||
|  | ||||
| import eu.kanade.domain.history.model.History | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import java.util.* | ||||
|  | ||||
| val historyMapper: (Long, Long, Date?, Date?) -> History = { id, chapterId, readAt, _ -> | ||||
|     History( | ||||
|         id = id, | ||||
|         chapterId = chapterId, | ||||
|         readAt = readAt, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| val historyWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date?) -> HistoryWithRelations = { | ||||
|     historyId, mangaId, chapterId, title, thumbnailUrl, chapterNumber, readAt -> | ||||
|     HistoryWithRelations( | ||||
|         id = historyId, | ||||
|         chapterId = chapterId, | ||||
|         mangaId = mangaId, | ||||
|         title = title, | ||||
|         thumbnailUrl = thumbnailUrl ?: "", | ||||
|         chapterNumber = chapterNumber, | ||||
|         readAt = readAt | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,91 @@ | ||||
| package eu.kanade.data.history | ||||
|  | ||||
| import androidx.paging.PagingSource | ||||
| import eu.kanade.data.DatabaseHandler | ||||
| import eu.kanade.data.chapter.chapterMapper | ||||
| import eu.kanade.data.manga.mangaMapper | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
| import eu.kanade.domain.manga.model.Manga | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
|  | ||||
| class HistoryRepositoryImpl( | ||||
|     private val handler: DatabaseHandler | ||||
| ) : HistoryRepository { | ||||
|  | ||||
|     override fun getHistory(query: String): PagingSource<Long, HistoryWithRelations> { | ||||
|         return handler.subscribeToPagingSource( | ||||
|             countQuery = { historyViewQueries.countHistory(query) }, | ||||
|             transacter = { historyViewQueries }, | ||||
|             queryProvider = { limit, offset -> | ||||
|                 historyViewQueries.history(query, limit, offset, historyWithRelationsMapper) | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter? { | ||||
|         val chapter = handler.awaitOne { chaptersQueries.getChapterById(chapterId, chapterMapper) } | ||||
|         val manga = handler.awaitOne { mangasQueries.getMangaById(mangaId, mangaMapper) } | ||||
|  | ||||
|         if (!chapter.read) { | ||||
|             return chapter | ||||
|         } | ||||
|  | ||||
|         val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { | ||||
|             Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) } | ||||
|             Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapterNumber.compareTo(c2.chapterNumber) } | ||||
|             Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) } | ||||
|             else -> throw NotImplementedError("Unknown sorting method") | ||||
|         } | ||||
|  | ||||
|         val chapters = handler.awaitList { chaptersQueries.getChapterByMangaId(mangaId, chapterMapper) } | ||||
|             .sortedWith(sortFunction) | ||||
|  | ||||
|         val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } | ||||
|         return when (manga.sorting) { | ||||
|             Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1) | ||||
|             Manga.CHAPTER_SORTING_NUMBER -> { | ||||
|                 val chapterNumber = chapter.chapterNumber | ||||
|  | ||||
|                 ((currChapterIndex + 1) until chapters.size) | ||||
|                     .map { chapters[it] } | ||||
|                     .firstOrNull { | ||||
|                         it.chapterNumber > chapterNumber && | ||||
|                             it.chapterNumber <= chapterNumber + 1 | ||||
|                     } | ||||
|             } | ||||
|             Manga.CHAPTER_SORTING_UPLOAD_DATE -> { | ||||
|                 chapters.drop(currChapterIndex + 1) | ||||
|                     .firstOrNull { it.dateUpload >= chapter.dateUpload } | ||||
|             } | ||||
|             else -> throw NotImplementedError("Unknown sorting method") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override suspend fun resetHistory(historyId: Long) { | ||||
|         try { | ||||
|             handler.await { historyQueries.resetHistoryById(historyId) } | ||||
|         } catch (e: Exception) { | ||||
|             logcat(throwable = e) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override suspend fun resetHistoryByMangaId(mangaId: Long) { | ||||
|         try { | ||||
|             handler.await { historyQueries.resetHistoryByMangaId(mangaId) } | ||||
|         } catch (e: Exception) { | ||||
|             logcat(throwable = e) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override suspend fun deleteAllHistory(): Boolean { | ||||
|         return try { | ||||
|             handler.await { historyQueries.removeAllHistory() } | ||||
|             true | ||||
|         } catch (e: Exception) { | ||||
|             logcat(throwable = e) | ||||
|             false | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										26
									
								
								app/src/main/java/eu/kanade/data/manga/MangaMapper.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/src/main/java/eu/kanade/data/manga/MangaMapper.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.data.manga | ||||
|  | ||||
| import eu.kanade.domain.manga.model.Manga | ||||
|  | ||||
| val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long) -> Manga = | ||||
|     { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewer, chapterFlags, coverLastModified, dateAdded -> | ||||
|         Manga( | ||||
|             id = id, | ||||
|             source = source, | ||||
|             favorite = favorite, | ||||
|             lastUpdate = lastUpdate ?: 0, | ||||
|             dateAdded = dateAdded, | ||||
|             viewerFlags = viewer, | ||||
|             chapterFlags = chapterFlags, | ||||
|             coverLastModified = coverLastModified, | ||||
|             url = url, | ||||
|             title = title, | ||||
|             artist = artist, | ||||
|             author = author, | ||||
|             description = description, | ||||
|             genre = genre, | ||||
|             status = status, | ||||
|             thumbnailUrl = thumbnailUrl, | ||||
|             initialized = initialized, | ||||
|         ) | ||||
|     } | ||||
							
								
								
									
										26
									
								
								app/src/main/java/eu/kanade/domain/DomainModule.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/src/main/java/eu/kanade/domain/DomainModule.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.domain | ||||
|  | ||||
| import eu.kanade.data.history.HistoryRepositoryImpl | ||||
| import eu.kanade.domain.history.interactor.DeleteHistoryTable | ||||
| import eu.kanade.domain.history.interactor.GetHistory | ||||
| import eu.kanade.domain.history.interactor.GetNextChapterForManga | ||||
| import eu.kanade.domain.history.interactor.RemoveHistoryById | ||||
| import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
| import uy.kohesive.injekt.api.InjektModule | ||||
| import uy.kohesive.injekt.api.InjektRegistrar | ||||
| import uy.kohesive.injekt.api.addFactory | ||||
| import uy.kohesive.injekt.api.addSingletonFactory | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class DomainModule : InjektModule { | ||||
|  | ||||
|     override fun InjektRegistrar.registerInjectables() { | ||||
|         addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } | ||||
|         addFactory { DeleteHistoryTable(get()) } | ||||
|         addFactory { GetHistory(get()) } | ||||
|         addFactory { GetNextChapterForManga(get()) } | ||||
|         addFactory { RemoveHistoryById(get()) } | ||||
|         addFactory { RemoveHistoryByMangaId(get()) } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| package eu.kanade.domain.chapter.model | ||||
|  | ||||
| data class Chapter( | ||||
|     val id: Long, | ||||
|     val mangaId: Long, | ||||
|     val read: Boolean, | ||||
|     val bookmark: Boolean, | ||||
|     val lastPageRead: Long, | ||||
|     val dateFetch: Long, | ||||
|     val sourceOrder: Long, | ||||
|     val url: String, | ||||
|     val name: String, | ||||
|     val dateUpload: Long, | ||||
|     val chapterNumber: Float, | ||||
|     val scanlator: String? | ||||
| ) | ||||
| @@ -0,0 +1,12 @@ | ||||
| package eu.kanade.domain.history.interactor | ||||
|  | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
|  | ||||
| class DeleteHistoryTable( | ||||
|     private val repository: HistoryRepository | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(): Boolean { | ||||
|         return repository.deleteAllHistory() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| package eu.kanade.domain.history.interactor | ||||
|  | ||||
| import androidx.paging.Pager | ||||
| import androidx.paging.PagingConfig | ||||
| import androidx.paging.PagingData | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
| import kotlinx.coroutines.flow.Flow | ||||
|  | ||||
| class GetHistory( | ||||
|     private val repository: HistoryRepository | ||||
| ) { | ||||
|  | ||||
|     fun subscribe(query: String): Flow<PagingData<HistoryWithRelations>> { | ||||
|         return Pager( | ||||
|             PagingConfig(pageSize = 25) | ||||
|         ) { | ||||
|             repository.getHistory(query) | ||||
|         }.flow | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| package eu.kanade.domain.history.interactor | ||||
|  | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
|  | ||||
| class GetNextChapterForManga( | ||||
|     private val repository: HistoryRepository | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(mangaId: Long, chapterId: Long): Chapter? { | ||||
|         return repository.getNextChapterForManga(mangaId, chapterId) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| package eu.kanade.domain.history.interactor | ||||
|  | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
|  | ||||
| class RemoveHistoryById( | ||||
|     private val repository: HistoryRepository | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(history: HistoryWithRelations) { | ||||
|         repository.resetHistory(history.id) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| package eu.kanade.domain.history.interactor | ||||
|  | ||||
| import eu.kanade.domain.history.repository.HistoryRepository | ||||
|  | ||||
| class RemoveHistoryByMangaId( | ||||
|     private val repository: HistoryRepository | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(mangaId: Long) { | ||||
|         repository.resetHistoryByMangaId(mangaId) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| package eu.kanade.domain.history.model | ||||
|  | ||||
| import java.util.* | ||||
|  | ||||
| data class History( | ||||
|     val id: Long?, | ||||
|     val chapterId: Long, | ||||
|     val readAt: Date? | ||||
| ) | ||||
| @@ -0,0 +1,13 @@ | ||||
| package eu.kanade.domain.history.model | ||||
|  | ||||
| import java.util.* | ||||
|  | ||||
| data class HistoryWithRelations( | ||||
|     val id: Long, | ||||
|     val chapterId: Long, | ||||
|     val mangaId: Long, | ||||
|     val title: String, | ||||
|     val thumbnailUrl: String, | ||||
|     val chapterNumber: Float, | ||||
|     val readAt: Date? | ||||
| ) | ||||
| @@ -0,0 +1,18 @@ | ||||
| package eu.kanade.domain.history.repository | ||||
|  | ||||
| import androidx.paging.PagingSource | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
|  | ||||
| interface HistoryRepository { | ||||
|  | ||||
|     fun getHistory(query: String): PagingSource<Long, HistoryWithRelations> | ||||
|  | ||||
|     suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter? | ||||
|  | ||||
|     suspend fun resetHistory(historyId: Long) | ||||
|  | ||||
|     suspend fun resetHistoryByMangaId(mangaId: Long) | ||||
|  | ||||
|     suspend fun deleteAllHistory(): Boolean | ||||
| } | ||||
							
								
								
									
										36
									
								
								app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| package eu.kanade.domain.manga.model | ||||
|  | ||||
| data class Manga( | ||||
|     val id: Long, | ||||
|     val source: Long, | ||||
|     val favorite: Boolean, | ||||
|     val lastUpdate: Long, | ||||
|     val dateAdded: Long, | ||||
|     val viewerFlags: Long, | ||||
|     val chapterFlags: Long, | ||||
|     val coverLastModified: Long, | ||||
|     val url: String, | ||||
|     val title: String, | ||||
|     val artist: String?, | ||||
|     val author: String?, | ||||
|     val description: String?, | ||||
|     val genre: List<String>?, | ||||
|     val status: Long, | ||||
|     val thumbnailUrl: String?, | ||||
|     val initialized: Boolean | ||||
| ) { | ||||
|  | ||||
|     val sorting: Long | ||||
|         get() = chapterFlags and CHAPTER_SORTING_MASK | ||||
|  | ||||
|     companion object { | ||||
|  | ||||
|         // Generic filter that does not filter anything | ||||
|         const val SHOW_ALL = 0x00000000L | ||||
|  | ||||
|         const val CHAPTER_SORTING_SOURCE = 0x00000000L | ||||
|         const val CHAPTER_SORTING_NUMBER = 0x00000100L | ||||
|         const val CHAPTER_SORTING_UPLOAD_DATE = 0x00000200L | ||||
|         const val CHAPTER_SORTING_MASK = 0x00000300L | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| package eu.kanade.presentation.components | ||||
|  | ||||
| import android.view.ViewGroup | ||||
| import androidx.annotation.StringRes | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.viewinterop.AndroidView | ||||
| import eu.kanade.tachiyomi.widget.EmptyView | ||||
|  | ||||
| @Composable | ||||
| fun EmptyScreen( | ||||
|     @StringRes textResource: Int, | ||||
|     actions: List<EmptyView.Action>? = null, | ||||
| ) { | ||||
|     EmptyScreen( | ||||
|         message = stringResource(id = textResource), | ||||
|         actions = actions, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun EmptyScreen( | ||||
|     message: String, | ||||
|     actions: List<EmptyView.Action>? = null, | ||||
| ) { | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .fillMaxSize() | ||||
|     ) { | ||||
|         AndroidView( | ||||
|             factory = { context -> | ||||
|                 EmptyView(context).apply { | ||||
|                     layoutParams = ViewGroup.LayoutParams( | ||||
|                         ViewGroup.LayoutParams.WRAP_CONTENT, | ||||
|                         ViewGroup.LayoutParams.WRAP_CONTENT, | ||||
|                     ) | ||||
|                 } | ||||
|             }, | ||||
|             modifier = Modifier | ||||
|                 .align(Alignment.Center), | ||||
|         ) { view -> | ||||
|             view.show(message, actions) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| package eu.kanade.presentation.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.aspectRatio | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.Shape | ||||
| import androidx.compose.ui.graphics.painter.ColorPainter | ||||
| import androidx.compose.ui.layout.ContentScale | ||||
| import androidx.compose.ui.unit.dp | ||||
| import coil.compose.AsyncImage | ||||
|  | ||||
| enum class MangaCoverAspect(val ratio: Float) { | ||||
|     SQUARE(1f / 1f), | ||||
|     COVER(2f / 3f) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun MangaCover( | ||||
|     modifier: Modifier = Modifier, | ||||
|     data: String?, | ||||
|     aspect: MangaCoverAspect, | ||||
|     contentDescription: String = "", | ||||
|     shape: Shape = RoundedCornerShape(4.dp) | ||||
| ) { | ||||
|     AsyncImage( | ||||
|         model = data, | ||||
|         placeholder = ColorPainter(CoverPlaceholderColor), | ||||
|         contentDescription = contentDescription, | ||||
|         modifier = modifier | ||||
|             .aspectRatio(aspect.ratio) | ||||
|             .clip(shape), | ||||
|         contentScale = ContentScale.Crop | ||||
|     ) | ||||
| } | ||||
|  | ||||
| private val CoverPlaceholderColor = Color(0x1F888888) | ||||
| @@ -0,0 +1,297 @@ | ||||
| package eu.kanade.presentation.history | ||||
|  | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.interaction.MutableInteractionSource | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.navigationBarsPadding | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| import androidx.compose.foundation.selection.toggleable | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.PlayArrow | ||||
| import androidx.compose.material.icons.outlined.Delete | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Checkbox | ||||
| import androidx.compose.material3.CircularProgressIndicator | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.IconButton | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||
| import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||
| import androidx.compose.ui.platform.ComposeView | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.platform.rememberNestedScrollInteropConnection | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.paging.compose.LazyPagingItems | ||||
| import androidx.paging.compose.collectAsLazyPagingItems | ||||
| import androidx.paging.compose.items | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.presentation.components.EmptyScreen | ||||
| import eu.kanade.presentation.components.MangaCover | ||||
| import eu.kanade.presentation.components.MangaCoverAspect | ||||
| import eu.kanade.presentation.util.horizontalPadding | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter | ||||
| import eu.kanade.tachiyomi.ui.recent.history.UiModel | ||||
| import eu.kanade.tachiyomi.util.lang.toRelativeString | ||||
| import eu.kanade.tachiyomi.util.lang.toTimestampString | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.text.DateFormat | ||||
| import java.text.DecimalFormat | ||||
| import java.text.DecimalFormatSymbols | ||||
| import java.util.Date | ||||
|  | ||||
| @Composable | ||||
| fun HistoryScreen( | ||||
|     composeView: ComposeView, | ||||
|     presenter: HistoryPresenter, | ||||
|     onClickItem: (HistoryWithRelations) -> Unit, | ||||
|     onClickResume: (HistoryWithRelations) -> Unit, | ||||
|     onClickDelete: (HistoryWithRelations, Boolean) -> Unit, | ||||
| ) { | ||||
|     val nestedScrollInterop = rememberNestedScrollInteropConnection(composeView) | ||||
|     val state by presenter.state.collectAsState() | ||||
|     val history = state.list?.collectAsLazyPagingItems() | ||||
|     when { | ||||
|         history == null -> { | ||||
|             CircularProgressIndicator() | ||||
|         } | ||||
|         history.itemCount == 0 -> { | ||||
|             EmptyScreen( | ||||
|                 textResource = R.string.information_no_recent_manga | ||||
|             ) | ||||
|         } | ||||
|         else -> { | ||||
|             HistoryContent( | ||||
|                 nestedScroll = nestedScrollInterop, | ||||
|                 history = history, | ||||
|                 onClickItem = onClickItem, | ||||
|                 onClickResume = onClickResume, | ||||
|                 onClickDelete = onClickDelete, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun HistoryContent( | ||||
|     history: LazyPagingItems<UiModel>, | ||||
|     onClickItem: (HistoryWithRelations) -> Unit, | ||||
|     onClickResume: (HistoryWithRelations) -> Unit, | ||||
|     onClickDelete: (HistoryWithRelations, Boolean) -> Unit, | ||||
|     preferences: PreferencesHelper = Injekt.get(), | ||||
|     nestedScroll: NestedScrollConnection | ||||
| ) { | ||||
|     val relativeTime: Int = remember { preferences.relativeTime().get() } | ||||
|     val dateFormat: DateFormat = remember { preferences.dateFormat() } | ||||
|  | ||||
|     val (removeState, setRemoveState) = remember { mutableStateOf<HistoryWithRelations?>(null) } | ||||
|  | ||||
|     val scrollState = rememberLazyListState() | ||||
|     LazyColumn( | ||||
|         modifier = Modifier | ||||
|             .nestedScroll(nestedScroll), | ||||
|         state = scrollState, | ||||
|     ) { | ||||
|         items(history) { item -> | ||||
|             when (item) { | ||||
|                 is UiModel.Header -> { | ||||
|                     HistoryHeader( | ||||
|                         modifier = Modifier | ||||
|                             .animateItemPlacement(), | ||||
|                         date = item.date, | ||||
|                         relativeTime = relativeTime, | ||||
|                         dateFormat = dateFormat | ||||
|                     ) | ||||
|                 } | ||||
|                 is UiModel.Item -> { | ||||
|                     val value = item.item | ||||
|                     HistoryItem( | ||||
|                         modifier = Modifier.animateItemPlacement(), | ||||
|                         history = value, | ||||
|                         onClickItem = { onClickItem(value) }, | ||||
|                         onClickResume = { onClickResume(value) }, | ||||
|                         onClickDelete = { setRemoveState(value) }, | ||||
|                     ) | ||||
|                 } | ||||
|                 null -> {} | ||||
|             } | ||||
|         } | ||||
|         item { | ||||
|             Spacer(Modifier.navigationBarsPadding()) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (removeState != null) { | ||||
|         RemoveHistoryDialog( | ||||
|             onPositive = { all -> | ||||
|                 onClickDelete(removeState, all) | ||||
|                 setRemoveState(null) | ||||
|             }, | ||||
|             onNegative = { setRemoveState(null) } | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun HistoryHeader( | ||||
|     modifier: Modifier = Modifier, | ||||
|     date: Date, | ||||
|     relativeTime: Int, | ||||
|     dateFormat: DateFormat, | ||||
| ) { | ||||
|     Text( | ||||
|         modifier = modifier | ||||
|             .padding(horizontal = horizontalPadding, vertical = 8.dp), | ||||
|         text = date.toRelativeString( | ||||
|             LocalContext.current, | ||||
|             relativeTime, | ||||
|             dateFormat | ||||
|         ), | ||||
|         style = MaterialTheme.typography.bodyMedium.copy( | ||||
|             color = MaterialTheme.colorScheme.onSurfaceVariant, | ||||
|             fontWeight = FontWeight.SemiBold, | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun HistoryItem( | ||||
|     modifier: Modifier = Modifier, | ||||
|     history: HistoryWithRelations, | ||||
|     onClickItem: () -> Unit, | ||||
|     onClickResume: () -> Unit, | ||||
|     onClickDelete: () -> Unit, | ||||
| ) { | ||||
|     Row( | ||||
|         modifier = modifier | ||||
|             .clickable(onClick = onClickItem) | ||||
|             .height(96.dp) | ||||
|             .padding(horizontal = horizontalPadding, vertical = 8.dp), | ||||
|         verticalAlignment = Alignment.CenterVertically, | ||||
|     ) { | ||||
|         MangaCover( | ||||
|             modifier = Modifier.fillMaxHeight(), | ||||
|             data = history.thumbnailUrl, | ||||
|             aspect = MangaCoverAspect.COVER | ||||
|         ) | ||||
|         Column( | ||||
|             modifier = Modifier | ||||
|                 .weight(1f) | ||||
|                 .padding(start = horizontalPadding, end = 8.dp), | ||||
|         ) { | ||||
|             val textStyle = MaterialTheme.typography.bodyMedium.copy( | ||||
|                 color = MaterialTheme.colorScheme.onSurface, | ||||
|             ) | ||||
|             Text( | ||||
|                 text = history.title, | ||||
|                 maxLines = 2, | ||||
|                 overflow = TextOverflow.Ellipsis, | ||||
|                 style = textStyle.copy(fontWeight = FontWeight.SemiBold) | ||||
|             ) | ||||
|             Row { | ||||
|                 Text( | ||||
|                     text = if (history.chapterNumber > -1) { | ||||
|                         stringResource( | ||||
|                             R.string.recent_manga_time, | ||||
|                             chapterFormatter.format(history.chapterNumber), | ||||
|                             history.readAt?.toTimestampString() ?: "", | ||||
|                         ) | ||||
|                     } else { | ||||
|                         history.readAt?.toTimestampString() ?: "" | ||||
|                     }, | ||||
|                     modifier = Modifier.padding(top = 4.dp), | ||||
|                     style = textStyle | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|         IconButton(onClick = onClickDelete) { | ||||
|             Icon( | ||||
|                 imageVector = Icons.Outlined.Delete, | ||||
|                 contentDescription = stringResource(id = R.string.action_delete), | ||||
|                 tint = MaterialTheme.colorScheme.onSurface, | ||||
|             ) | ||||
|         } | ||||
|         IconButton(onClick = onClickResume) { | ||||
|             Icon( | ||||
|                 imageVector = Icons.Filled.PlayArrow, | ||||
|                 contentDescription = stringResource(id = R.string.action_resume), | ||||
|                 tint = MaterialTheme.colorScheme.onSurface, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun RemoveHistoryDialog( | ||||
|     onPositive: (Boolean) -> Unit, | ||||
|     onNegative: () -> Unit | ||||
| ) { | ||||
|     val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) } | ||||
|  | ||||
|     AlertDialog( | ||||
|         title = { | ||||
|             Text(text = stringResource(id = R.string.action_remove)) | ||||
|         }, | ||||
|         text = { | ||||
|             Column { | ||||
|                 Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description)) | ||||
|                 Row( | ||||
|                     modifier = Modifier | ||||
|                         .padding(top = 16.dp) | ||||
|                         .toggleable( | ||||
|                             interactionSource = remember { MutableInteractionSource() }, | ||||
|                             indication = null, | ||||
|                             value = removeEverything, | ||||
|                             onValueChange = removeEverythingState | ||||
|                         ), | ||||
|                     verticalAlignment = Alignment.CenterVertically | ||||
|                 ) { | ||||
|                     Checkbox( | ||||
|                         checked = removeEverything, | ||||
|                         onCheckedChange = null, | ||||
|                     ) | ||||
|                     Text( | ||||
|                         modifier = Modifier.padding(start = 4.dp), | ||||
|                         text = stringResource(id = R.string.dialog_with_checkbox_reset) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         onDismissRequest = onNegative, | ||||
|         confirmButton = { | ||||
|             TextButton(onClick = { onPositive(removeEverything) }) { | ||||
|                 Text(text = stringResource(id = R.string.action_remove)) | ||||
|             } | ||||
|         }, | ||||
|         dismissButton = { | ||||
|             TextButton(onClick = onNegative) { | ||||
|                 Text(text = stringResource(id = R.string.action_cancel)) | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| private val chapterFormatter = DecimalFormat( | ||||
|     "#.###", | ||||
|     DecimalFormatSymbols().apply { decimalSeparator = '.' }, | ||||
| ) | ||||
| @@ -0,0 +1,20 @@ | ||||
| package eu.kanade.presentation.theme | ||||
|  | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import com.google.android.material.composethemeadapter3.createMdc3Theme | ||||
|  | ||||
| @Composable | ||||
| fun TachiyomiTheme(content: @Composable () -> Unit) { | ||||
|     val context = LocalContext.current | ||||
|     val (colorScheme, typography) = createMdc3Theme( | ||||
|         context = context | ||||
|     ) | ||||
|  | ||||
|     MaterialTheme( | ||||
|         colorScheme = colorScheme!!, | ||||
|         typography = typography!!, | ||||
|         content = content | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| package eu.kanade.presentation.util | ||||
|  | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | ||||
| val horizontalPadding = 16.dp | ||||
| @@ -0,0 +1,5 @@ | ||||
| package eu.kanade.presentation.util | ||||
|  | ||||
| import androidx.compose.foundation.lazy.LazyListState | ||||
|  | ||||
| fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 | ||||
| @@ -24,6 +24,7 @@ import coil.decode.GifDecoder | ||||
| import coil.decode.ImageDecoderDecoder | ||||
| import coil.disk.DiskCache | ||||
| import coil.util.DebugLogger | ||||
| import eu.kanade.domain.DomainModule | ||||
| import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher | ||||
| import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer | ||||
| import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder | ||||
| @@ -75,6 +76,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { | ||||
|         } | ||||
|  | ||||
|         Injekt.importModule(AppModule(this)) | ||||
|         Injekt.importModule(DomainModule()) | ||||
|  | ||||
|         setupAcra() | ||||
|         setupNotificationChannels() | ||||
|   | ||||
| @@ -2,9 +2,18 @@ package eu.kanade.tachiyomi | ||||
|  | ||||
| import android.app.Application | ||||
| import androidx.core.content.ContextCompat | ||||
| import com.squareup.sqldelight.android.AndroidSqliteDriver | ||||
| import com.squareup.sqldelight.db.SqlDriver | ||||
| import data.History | ||||
| import data.Mangas | ||||
| import eu.kanade.data.AndroidDatabaseHandler | ||||
| import eu.kanade.data.DatabaseHandler | ||||
| import eu.kanade.data.dateAdapter | ||||
| import eu.kanade.data.listOfStringsAdapter | ||||
| import eu.kanade.tachiyomi.data.cache.ChapterCache | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.DbOpenCallback | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.saver.ImageSaver | ||||
| @@ -25,11 +34,37 @@ class AppModule(val app: Application) : InjektModule { | ||||
|     override fun InjektRegistrar.registerInjectables() { | ||||
|         addSingleton(app) | ||||
|  | ||||
|         addSingletonFactory { DbOpenCallback() } | ||||
|  | ||||
|         addSingletonFactory<SqlDriver> { | ||||
|             AndroidSqliteDriver( | ||||
|                 schema = Database.Schema, | ||||
|                 context = app, | ||||
|                 name = DbOpenCallback.DATABASE_NAME, | ||||
|                 callback = get<DbOpenCallback>() | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         addSingletonFactory { | ||||
|             Database( | ||||
|                 driver = get(), | ||||
|                 historyAdapter = History.Adapter( | ||||
|                     history_last_readAdapter = dateAdapter, | ||||
|                     history_time_readAdapter = dateAdapter | ||||
|                 ), | ||||
|                 mangasAdapter = Mangas.Adapter( | ||||
|                     genreAdapter = listOfStringsAdapter | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         addSingletonFactory<DatabaseHandler> { AndroidDatabaseHandler(get(), get()) } | ||||
|  | ||||
|         addSingletonFactory { Json { ignoreUnknownKeys = true } } | ||||
|  | ||||
|         addSingletonFactory { PreferencesHelper(app) } | ||||
|  | ||||
|         addSingletonFactory { DatabaseHelper(app) } | ||||
|         addSingletonFactory { DatabaseHelper(app, get()) } | ||||
|  | ||||
|         addSingletonFactory { ChapterCache(app) } | ||||
|  | ||||
| @@ -57,6 +92,8 @@ class AppModule(val app: Application) : InjektModule { | ||||
|  | ||||
|             get<SourceManager>() | ||||
|  | ||||
|             get<Database>() | ||||
|  | ||||
|             get<DatabaseHelper>() | ||||
|  | ||||
|             get<DownloadManager>() | ||||
|   | ||||
| @@ -299,7 +299,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() | ||||
|         databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -168,7 +168,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() | ||||
|         databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -26,12 +26,15 @@ import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory | ||||
| /** | ||||
|  * This class provides operations to manage the database through its interfaces. | ||||
|  */ | ||||
| open class DatabaseHelper(context: Context) : | ||||
| open class DatabaseHelper( | ||||
|     context: Context, | ||||
|     callback: DbOpenCallback | ||||
| ) : | ||||
|     MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { | ||||
|  | ||||
|     private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) | ||||
|         .name(DbOpenCallback.DATABASE_NAME) | ||||
|         .callback(DbOpenCallback()) | ||||
|         .callback(callback) | ||||
|         .build() | ||||
|  | ||||
|     override val db = DefaultStorIOSQLite.builder() | ||||
|   | ||||
| @@ -2,98 +2,28 @@ package eu.kanade.tachiyomi.data.database | ||||
|  | ||||
| import androidx.sqlite.db.SupportSQLiteDatabase | ||||
| import androidx.sqlite.db.SupportSQLiteOpenHelper | ||||
| import eu.kanade.tachiyomi.data.database.tables.CategoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable | ||||
| import com.squareup.sqldelight.android.AndroidSqliteDriver | ||||
| import eu.kanade.tachiyomi.Database | ||||
|  | ||||
| class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { | ||||
| class DbOpenCallback : SupportSQLiteOpenHelper.Callback(Database.Schema.version) { | ||||
|  | ||||
|     companion object { | ||||
|         /** | ||||
|          * Name of the database file. | ||||
|          */ | ||||
|         const val DATABASE_NAME = "tachiyomi.db" | ||||
|  | ||||
|         /** | ||||
|          * Version of the database. | ||||
|          */ | ||||
|         const val DATABASE_VERSION = 14 | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(db: SupportSQLiteDatabase) = with(db) { | ||||
|         execSQL(MangaTable.createTableQuery) | ||||
|         execSQL(ChapterTable.createTableQuery) | ||||
|         execSQL(TrackTable.createTableQuery) | ||||
|         execSQL(CategoryTable.createTableQuery) | ||||
|         execSQL(MangaCategoryTable.createTableQuery) | ||||
|         execSQL(HistoryTable.createTableQuery) | ||||
|  | ||||
|         // DB indexes | ||||
|         execSQL(MangaTable.createUrlIndexQuery) | ||||
|         execSQL(MangaTable.createLibraryIndexQuery) | ||||
|         execSQL(ChapterTable.createMangaIdIndexQuery) | ||||
|         execSQL(ChapterTable.createUnreadChaptersIndexQuery) | ||||
|         execSQL(HistoryTable.createChapterIdIndexQuery) | ||||
|     override fun onCreate(db: SupportSQLiteDatabase) { | ||||
|         Database.Schema.create(AndroidSqliteDriver(database = db, cacheSize = 1)) | ||||
|     } | ||||
|  | ||||
|     override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { | ||||
|         if (oldVersion < 2) { | ||||
|             db.execSQL(ChapterTable.sourceOrderUpdateQuery) | ||||
|  | ||||
|             // Fix kissmanga covers after supporting cloudflare | ||||
|             db.execSQL( | ||||
|                 """UPDATE mangas SET thumbnail_url = | ||||
|                     REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""", | ||||
|             ) | ||||
|         } | ||||
|         if (oldVersion < 3) { | ||||
|             // Initialize history tables | ||||
|             db.execSQL(HistoryTable.createTableQuery) | ||||
|             db.execSQL(HistoryTable.createChapterIdIndexQuery) | ||||
|         } | ||||
|         if (oldVersion < 4) { | ||||
|             db.execSQL(ChapterTable.bookmarkUpdateQuery) | ||||
|         } | ||||
|         if (oldVersion < 5) { | ||||
|             db.execSQL(ChapterTable.addScanlator) | ||||
|         } | ||||
|         if (oldVersion < 6) { | ||||
|             db.execSQL(TrackTable.addTrackingUrl) | ||||
|         } | ||||
|         if (oldVersion < 7) { | ||||
|             db.execSQL(TrackTable.addLibraryId) | ||||
|         } | ||||
|         if (oldVersion < 8) { | ||||
|             db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index") | ||||
|             db.execSQL(MangaTable.createLibraryIndexQuery) | ||||
|             db.execSQL(ChapterTable.createUnreadChaptersIndexQuery) | ||||
|         } | ||||
|         if (oldVersion < 9) { | ||||
|             db.execSQL(TrackTable.addStartDate) | ||||
|             db.execSQL(TrackTable.addFinishDate) | ||||
|         } | ||||
|         if (oldVersion < 10) { | ||||
|             db.execSQL(MangaTable.addCoverLastModified) | ||||
|         } | ||||
|         if (oldVersion < 11) { | ||||
|             db.execSQL(MangaTable.addDateAdded) | ||||
|             db.execSQL(MangaTable.backfillDateAdded) | ||||
|         } | ||||
|         if (oldVersion < 12) { | ||||
|             db.execSQL(MangaTable.addNextUpdateCol) | ||||
|         } | ||||
|         if (oldVersion < 13) { | ||||
|             db.execSQL(TrackTable.renameTableToTemp) | ||||
|             db.execSQL(TrackTable.createTableQuery) | ||||
|             db.execSQL(TrackTable.insertFromTempTable) | ||||
|             db.execSQL(TrackTable.dropTempTable) | ||||
|         } | ||||
|         if (oldVersion < 14) { | ||||
|             db.execSQL(ChapterTable.fixDateUploadIfNeeded) | ||||
|         } | ||||
|         Database.Schema.migrate( | ||||
|             driver = AndroidSqliteDriver(database = db, cacheSize = 1), | ||||
|             oldVersion = oldVersion, | ||||
|             newVersion = newVersion | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onConfigure(db: SupportSQLiteDatabase) { | ||||
|   | ||||
| @@ -4,39 +4,11 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.RawQuery | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.HistoryUpsertResolver | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable | ||||
| import java.util.Date | ||||
|  | ||||
| interface HistoryQueries : DbProvider { | ||||
|  | ||||
|     /** | ||||
|      * Insert history into database | ||||
|      * @param history object containing history information | ||||
|      */ | ||||
|     fun insertHistory(history: History) = db.put().`object`(history).prepare() | ||||
|  | ||||
|     /** | ||||
|      * Returns history of recent manga containing last read chapter | ||||
|      * @param date recent date range | ||||
|      * @param limit the limit of manga to grab | ||||
|      * @param offset offset the db by | ||||
|      * @param search what to search in the db history | ||||
|      */ | ||||
|     fun getRecentManga(date: Date, limit: Int = 25, offset: Int = 0, search: String = "") = db.get() | ||||
|         .listOfObjects(MangaChapterHistory::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getRecentMangasQuery(search)) | ||||
|                 .args(date.time, limit, offset) | ||||
|                 .observesTables(HistoryTable.TABLE) | ||||
|                 .build(), | ||||
|         ) | ||||
|         .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getHistoryByMangaId(mangaId: Long) = db.get() | ||||
|         .listOfObjects(History::class.java) | ||||
|         .withQuery( | ||||
| @@ -64,9 +36,9 @@ interface HistoryQueries : DbProvider { | ||||
|      * Inserts history object if not yet in database | ||||
|      * @param history history object | ||||
|      */ | ||||
|     fun updateHistoryLastRead(history: History) = db.put() | ||||
|     fun upsertHistoryLastRead(history: History) = db.put() | ||||
|         .`object`(history) | ||||
|         .withPutResolver(HistoryLastReadPutResolver()) | ||||
|         .withPutResolver(HistoryUpsertResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     /** | ||||
| @@ -74,12 +46,12 @@ interface HistoryQueries : DbProvider { | ||||
|      * Inserts history object if not yet in database | ||||
|      * @param historyList history object list | ||||
|      */ | ||||
|     fun updateHistoryLastRead(historyList: List<History>) = db.put() | ||||
|     fun upsertHistoryLastRead(historyList: List<History>) = db.put() | ||||
|         .objects(historyList) | ||||
|         .withPutResolver(HistoryLastReadPutResolver()) | ||||
|         .withPutResolver(HistoryUpsertResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun deleteHistory() = db.delete() | ||||
|     fun dropHistoryTable() = db.delete() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(HistoryTable.TABLE) | ||||
|   | ||||
| @@ -70,7 +70,8 @@ fun getRecentMangasQuery(search: String = "") = | ||||
|     SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID}, MAX(${History.TABLE}.${History.COL_LAST_READ}) as ${History.COL_LAST_READ} | ||||
|     FROM ${Chapter.TABLE} JOIN ${History.TABLE} | ||||
|     ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} | ||||
|     GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read | ||||
|     GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} | ||||
|     ) AS max_last_read | ||||
|     ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID} | ||||
|     WHERE ${History.TABLE}.${History.COL_LAST_READ} > ? | ||||
|     AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.mappers.HistoryPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable | ||||
| 
 | ||||
| class HistoryLastReadPutResolver : HistoryPutResolver() { | ||||
| class HistoryUpsertResolver : HistoryPutResolver() { | ||||
| 
 | ||||
|     /** | ||||
|      * Updates last_read time of chapter | ||||
| @@ -11,13 +11,4 @@ object CategoryTable { | ||||
|     const val COL_ORDER = "sort" | ||||
|  | ||||
|     const val COL_FLAGS = "flags" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_NAME TEXT NOT NULL, | ||||
|             $COL_ORDER INTEGER NOT NULL, | ||||
|             $COL_FLAGS INTEGER NOT NULL | ||||
|             )""" | ||||
| } | ||||
|   | ||||
| @@ -27,42 +27,4 @@ object ChapterTable { | ||||
|     const val COL_CHAPTER_NUMBER = "chapter_number" | ||||
|  | ||||
|     const val COL_SOURCE_ORDER = "source_order" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_URL TEXT NOT NULL, | ||||
|             $COL_NAME TEXT NOT NULL, | ||||
|             $COL_SCANLATOR TEXT, | ||||
|             $COL_READ BOOLEAN NOT NULL, | ||||
|             $COL_BOOKMARK BOOLEAN NOT NULL, | ||||
|             $COL_LAST_PAGE_READ INT NOT NULL, | ||||
|             $COL_CHAPTER_NUMBER FLOAT NOT NULL, | ||||
|             $COL_SOURCE_ORDER INTEGER NOT NULL, | ||||
|             $COL_DATE_FETCH LONG NOT NULL, | ||||
|             $COL_DATE_UPLOAD LONG NOT NULL, | ||||
|             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
|             )""" | ||||
|  | ||||
|     val createMangaIdIndexQuery: String | ||||
|         get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)" | ||||
|  | ||||
|     val createUnreadChaptersIndexQuery: String | ||||
|         get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " + | ||||
|             "WHERE $COL_READ = 0" | ||||
|  | ||||
|     val sourceOrderUpdateQuery: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" | ||||
|  | ||||
|     val bookmarkUpdateQuery: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_BOOKMARK BOOLEAN DEFAULT FALSE" | ||||
|  | ||||
|     val addScanlator: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SCANLATOR TEXT DEFAULT NULL" | ||||
|  | ||||
|     val fixDateUploadIfNeeded: String | ||||
|         get() = "UPDATE $TABLE SET $COL_DATE_UPLOAD = $COL_DATE_FETCH WHERE $COL_DATE_UPLOAD = 0" | ||||
| } | ||||
|   | ||||
| @@ -26,24 +26,4 @@ object HistoryTable { | ||||
|      * Time read column name | ||||
|      */ | ||||
|     const val COL_TIME_READ = "${TABLE}_time_read" | ||||
|  | ||||
|     /** | ||||
|      * query to create history table | ||||
|      */ | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_CHAPTER_ID INTEGER NOT NULL UNIQUE, | ||||
|             $COL_LAST_READ LONG, | ||||
|             $COL_TIME_READ LONG, | ||||
|             FOREIGN KEY($COL_CHAPTER_ID) REFERENCES ${ChapterTable.TABLE} (${ChapterTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
|             )""" | ||||
|  | ||||
|     /** | ||||
|      * query to index history chapter id | ||||
|      */ | ||||
|     val createChapterIdIndexQuery: String | ||||
|         get() = "CREATE INDEX ${TABLE}_${COL_CHAPTER_ID}_index ON $TABLE($COL_CHAPTER_ID)" | ||||
| } | ||||
|   | ||||
| @@ -9,16 +9,4 @@ object MangaCategoryTable { | ||||
|     const val COL_MANGA_ID = "manga_id" | ||||
|  | ||||
|     const val COL_CATEGORY_ID = "category_id" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_CATEGORY_ID INTEGER NOT NULL, | ||||
|             FOREIGN KEY($COL_CATEGORY_ID) REFERENCES ${CategoryTable.TABLE} (${CategoryTable.COL_ID}) | ||||
|             ON DELETE CASCADE, | ||||
|             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
|             )""" | ||||
| } | ||||
|   | ||||
| @@ -47,53 +47,4 @@ object MangaTable { | ||||
|     const val COMPUTED_COL_UNREAD_COUNT = "unread_count" | ||||
|  | ||||
|     const val COMPUTED_COL_READ_COUNT = "read_count" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_SOURCE INTEGER NOT NULL, | ||||
|             $COL_URL TEXT NOT NULL, | ||||
|             $COL_ARTIST TEXT, | ||||
|             $COL_AUTHOR TEXT, | ||||
|             $COL_DESCRIPTION TEXT, | ||||
|             $COL_GENRE TEXT, | ||||
|             $COL_TITLE TEXT NOT NULL, | ||||
|             $COL_STATUS INTEGER NOT NULL, | ||||
|             $COL_THUMBNAIL_URL TEXT, | ||||
|             $COL_FAVORITE INTEGER NOT NULL, | ||||
|             $COL_LAST_UPDATE LONG, | ||||
|             $COL_NEXT_UPDATE LONG, | ||||
|             $COL_INITIALIZED BOOLEAN NOT NULL, | ||||
|             $COL_VIEWER INTEGER NOT NULL, | ||||
|             $COL_CHAPTER_FLAGS INTEGER NOT NULL, | ||||
|             $COL_COVER_LAST_MODIFIED LONG NOT NULL, | ||||
|             $COL_DATE_ADDED LONG NOT NULL | ||||
|             )""" | ||||
|  | ||||
|     val createUrlIndexQuery: String | ||||
|         get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)" | ||||
|  | ||||
|     val createLibraryIndexQuery: String | ||||
|         get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " + | ||||
|             "WHERE $COL_FAVORITE = 1" | ||||
|  | ||||
|     val addCoverLastModified: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_COVER_LAST_MODIFIED LONG NOT NULL DEFAULT 0" | ||||
|  | ||||
|     val addDateAdded: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_DATE_ADDED LONG NOT NULL DEFAULT 0" | ||||
|  | ||||
|     /** | ||||
|      * Used with addDateAdded to populate it with the oldest chapter fetch date. | ||||
|      */ | ||||
|     val backfillDateAdded: String | ||||
|         get() = "UPDATE $TABLE SET $COL_DATE_ADDED = " + | ||||
|             "(SELECT MIN(${ChapterTable.COL_DATE_FETCH}) " + | ||||
|             "FROM $TABLE INNER JOIN ${ChapterTable.TABLE} " + | ||||
|             "ON $TABLE.$COL_ID = ${ChapterTable.TABLE}.${ChapterTable.COL_MANGA_ID} " + | ||||
|             "GROUP BY $TABLE.$COL_ID)" | ||||
|  | ||||
|     val addNextUpdateCol: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_NEXT_UPDATE LONG DEFAULT 0" | ||||
| } | ||||
|   | ||||
| @@ -30,43 +30,6 @@ object TrackTable { | ||||
|  | ||||
|     const val COL_FINISH_DATE = "finish_date" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_SYNC_ID INTEGER NOT NULL, | ||||
|             $COL_MEDIA_ID INTEGER NOT NULL, | ||||
|             $COL_LIBRARY_ID INTEGER, | ||||
|             $COL_TITLE TEXT NOT NULL, | ||||
|             $COL_LAST_CHAPTER_READ REAL NOT NULL, | ||||
|             $COL_TOTAL_CHAPTERS INTEGER NOT NULL, | ||||
|             $COL_STATUS INTEGER NOT NULL, | ||||
|             $COL_SCORE FLOAT NOT NULL, | ||||
|             $COL_TRACKING_URL TEXT NOT NULL, | ||||
|             $COL_START_DATE LONG NOT NULL, | ||||
|             $COL_FINISH_DATE LONG NOT NULL, | ||||
|             UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, | ||||
|             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
|             )""" | ||||
|  | ||||
|     val addTrackingUrl: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''" | ||||
|  | ||||
|     val addLibraryId: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL" | ||||
|  | ||||
|     val addStartDate: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0" | ||||
|  | ||||
|     val addFinishDate: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0" | ||||
|  | ||||
|     val renameTableToTemp: String | ||||
|         get() = | ||||
|             "ALTER TABLE $TABLE RENAME TO ${TABLE}_tmp" | ||||
|  | ||||
|     val insertFromTempTable: String | ||||
|         get() = | ||||
|             """ | ||||
| @@ -74,7 +37,4 @@ object TrackTable { | ||||
|             |SELECT $COL_ID,$COL_MANGA_ID,$COL_SYNC_ID,$COL_MEDIA_ID,$COL_LIBRARY_ID,$COL_TITLE,$COL_LAST_CHAPTER_READ,$COL_TOTAL_CHAPTERS,$COL_STATUS,$COL_SCORE,$COL_TRACKING_URL,$COL_START_DATE,$COL_FINISH_DATE | ||||
|             |FROM ${TABLE}_tmp | ||||
|             """.trimMargin() | ||||
|  | ||||
|     val dropTempTable: String | ||||
|         get() = "DROP TABLE ${TABLE}_tmp" | ||||
| } | ||||
|   | ||||
| @@ -166,7 +166,7 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|      * @param chapterId id of chapter | ||||
|      */ | ||||
|     private fun openChapter(context: Context, mangaId: Long, chapterId: Long) { | ||||
|         val db = DatabaseHelper(context) | ||||
|         val db = Injekt.get<DatabaseHelper>() | ||||
|         val manga = db.getManga(mangaId).executeAsBlocking() | ||||
|         val chapter = db.getChapter(chapterId).executeAsBlocking() | ||||
|         if (manga != null && chapter != null) { | ||||
|   | ||||
| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.tachiyomi.ui.base.controller | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import androidx.compose.runtime.Composable | ||||
| import eu.kanade.presentation.theme.TachiyomiTheme | ||||
| import eu.kanade.tachiyomi.databinding.ComposeControllerBinding | ||||
| import nucleus.presenter.Presenter | ||||
|  | ||||
| abstract class ComposeController<P : Presenter<*>> : NucleusController<ComposeControllerBinding, P>() { | ||||
|  | ||||
|     override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding = | ||||
|         ComposeControllerBinding.inflate(inflater) | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         binding.root.setContent { | ||||
|             TachiyomiTheme { | ||||
|                 ComposeContent() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable abstract fun ComposeContent() | ||||
| } | ||||
| @@ -35,6 +35,7 @@ import com.google.android.material.snackbar.Snackbar | ||||
| import dev.chrisbanes.insetter.applyInsetter | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| @@ -118,6 +119,8 @@ class MangaController : | ||||
|     DownloadCustomChaptersDialog.Listener, | ||||
|     DeleteChaptersDialog.Listener { | ||||
|  | ||||
|     constructor(history: HistoryWithRelations) : this(history.mangaId) | ||||
|  | ||||
|     constructor(manga: Manga?, fromSource: Boolean = false) : super( | ||||
|         bundleOf( | ||||
|             MANGA_EXTRA to (manga?.id ?: 0), | ||||
|   | ||||
| @@ -1,24 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.info | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.AttributeSet | ||||
| import androidx.appcompat.widget.AppCompatImageView | ||||
| import kotlin.math.min | ||||
|  | ||||
| /** | ||||
|  * A custom ImageView for holding a manga cover with: | ||||
|  * - width: min(maxWidth attr, 33% of parent width) | ||||
|  * - height: 2:3 width:height ratio | ||||
|  * | ||||
|  * Should be defined with a width of match_parent. | ||||
|  */ | ||||
| class MangaCoverImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) { | ||||
|  | ||||
|     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | ||||
|         super.onMeasure(widthMeasureSpec, heightMeasureSpec) | ||||
|  | ||||
|         val width = min(maxWidth, measuredWidth / 3) | ||||
|         val height = width / 2 * 3 | ||||
|         setMeasuredDimension(width, height) | ||||
|     } | ||||
| } | ||||
| @@ -97,14 +97,19 @@ import kotlin.math.max | ||||
| class ReaderActivity : BaseRxActivity<ReaderPresenter>() { | ||||
|  | ||||
|     companion object { | ||||
|         fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent { | ||||
|  | ||||
|         fun newIntent(context: Context, mangaId: Long?, chapterId: Long?): Intent { | ||||
|             return Intent(context, ReaderActivity::class.java).apply { | ||||
|                 putExtra("manga", manga.id) | ||||
|                 putExtra("chapter", chapter.id) | ||||
|                 putExtra("manga", mangaId) | ||||
|                 putExtra("chapter", chapterId) | ||||
|                 addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent { | ||||
|             return newIntent(context, manga.id, chapter.id) | ||||
|         } | ||||
|  | ||||
|         private const val ENABLED_BUTTON_IMAGE_ALPHA = 255 | ||||
|         private const val DISABLED_BUTTON_IMAGE_ALPHA = 64 | ||||
|  | ||||
|   | ||||
| @@ -449,7 +449,7 @@ class ReaderPresenter( | ||||
|     private fun saveChapterHistory(chapter: ReaderChapter) { | ||||
|         if (!incognitoMode) { | ||||
|             val history = History.create(chapter.chapter).apply { last_read = Date().time } | ||||
|             db.updateHistoryLastRead(history).asRxCompletable() | ||||
|             db.upsertHistoryLastRead(history).asRxCompletable() | ||||
|                 .onErrorComplete() | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|   | ||||
| @@ -0,0 +1,21 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class ClearHistoryDialogController : DialogController() { | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         return MaterialAlertDialogBuilder(activity!!) | ||||
|             .setMessage(R.string.clear_history_confirmation) | ||||
|             .setPositiveButton(android.R.string.ok) { _, _ -> | ||||
|                 (targetController as? HistoryController) | ||||
|                     ?.presenter | ||||
|                     ?.deleteAllHistory() | ||||
|             } | ||||
|             .setNegativeButton(android.R.string.cancel, null) | ||||
|             .create() | ||||
|     } | ||||
| } | ||||
| @@ -1,51 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.IFlexible | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.DecimalFormat | ||||
| import java.text.DecimalFormatSymbols | ||||
|  | ||||
| /** | ||||
|  * Adapter of HistoryHolder. | ||||
|  * Connection between Fragment and Holder | ||||
|  * Holder updates should be called from here. | ||||
|  * | ||||
|  * @param controller a HistoryController object | ||||
|  * @constructor creates an instance of the adapter. | ||||
|  */ | ||||
| class HistoryAdapter(controller: HistoryController) : | ||||
|     FlexibleAdapter<IFlexible<*>>(null, controller, true) { | ||||
|  | ||||
|     val sourceManager: SourceManager by injectLazy() | ||||
|  | ||||
|     val resumeClickListener: OnResumeClickListener = controller | ||||
|     val removeClickListener: OnRemoveClickListener = controller | ||||
|     val itemClickListener: OnItemClickListener = controller | ||||
|  | ||||
|     /** | ||||
|      * DecimalFormat used to display correct chapter number | ||||
|      */ | ||||
|     val decimalFormat = DecimalFormat( | ||||
|         "#.###", | ||||
|         DecimalFormatSymbols() | ||||
|             .apply { decimalSeparator = '.' }, | ||||
|     ) | ||||
|  | ||||
|     init { | ||||
|         setDisplayHeadersAtStartUp(true) | ||||
|     } | ||||
|  | ||||
|     interface OnResumeClickListener { | ||||
|         fun onResumeClick(position: Int) | ||||
|     } | ||||
|  | ||||
|     interface OnRemoveClickListener { | ||||
|         fun onRemoveClick(position: Int) | ||||
|     } | ||||
|  | ||||
|     interface OnItemClickListener { | ||||
|         fun onItemClick(position: Int) | ||||
|     } | ||||
| } | ||||
| @@ -1,193 +1,53 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.Menu | ||||
| import android.view.MenuInflater | ||||
| import android.view.MenuItem | ||||
| import android.view.View | ||||
| import androidx.appcompat.widget.SearchView | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import dev.chrisbanes.insetter.applyInsetter | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import androidx.compose.runtime.Composable | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
| import eu.kanade.presentation.history.HistoryScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreService | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.databinding.HistoryControllerBinding | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.ComposeController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RootController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction | ||||
| import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import eu.kanade.tachiyomi.util.view.onAnimationsFinished | ||||
| import kotlinx.coroutines.flow.drop | ||||
| import kotlinx.coroutines.flow.filter | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import logcat.LogPriority | ||||
| import reactivecircus.flowbinding.appcompat.queryTextChanges | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * Fragment that shows recently read manga. | ||||
|  */ | ||||
| class HistoryController : | ||||
|     NucleusController<HistoryControllerBinding, HistoryPresenter>(), | ||||
|     RootController, | ||||
|     FlexibleAdapter.OnUpdateListener, | ||||
|     FlexibleAdapter.EndlessScrollListener, | ||||
|     HistoryAdapter.OnRemoveClickListener, | ||||
|     HistoryAdapter.OnResumeClickListener, | ||||
|     HistoryAdapter.OnItemClickListener, | ||||
|     RemoveHistoryDialog.Listener { | ||||
| class HistoryController : ComposeController<HistoryPresenter>(), RootController { | ||||
|  | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the recent manga. | ||||
|      */ | ||||
|     var adapter: HistoryAdapter? = null | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Endless loading item. | ||||
|      */ | ||||
|     private var progressItem: ProgressItem? = null | ||||
|  | ||||
|     /** | ||||
|      * Search query. | ||||
|      */ | ||||
|     private var query = "" | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_recent_manga) | ||||
|     } | ||||
|     override fun getTitle() = resources?.getString(R.string.label_recent_manga) | ||||
|  | ||||
|     override fun createPresenter(): HistoryPresenter { | ||||
|         return HistoryPresenter() | ||||
|     } | ||||
|     override fun createPresenter() = HistoryPresenter() | ||||
|  | ||||
|     override fun createBinding(inflater: LayoutInflater) = HistoryControllerBinding.inflate(inflater) | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         binding.recycler.applyInsetter { | ||||
|             type(navigationBars = true) { | ||||
|                 padding() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Initialize adapter | ||||
|         binding.recycler.layoutManager = LinearLayoutManager(view.context) | ||||
|         adapter = HistoryAdapter(this@HistoryController) | ||||
|         binding.recycler.setHasFixedSize(true) | ||||
|         binding.recycler.adapter = adapter | ||||
|         adapter?.fastScroller = binding.fastScroller | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Populate adapter with chapters | ||||
|      * | ||||
|      * @param mangaHistory list of manga history | ||||
|      */ | ||||
|     fun onNextManga(mangaHistory: List<HistoryItem>, cleanBatch: Boolean = false) { | ||||
|         if (adapter?.itemCount ?: 0 == 0) { | ||||
|             resetProgressItem() | ||||
|         } | ||||
|         if (cleanBatch) { | ||||
|             adapter?.updateDataSet(mangaHistory) | ||||
|         } else { | ||||
|             adapter?.onLoadMoreComplete(mangaHistory) | ||||
|         } | ||||
|         binding.recycler.onAnimationsFinished { | ||||
|             (activity as? MainActivity)?.ready = true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Safely error if next page load fails | ||||
|      */ | ||||
|     fun onAddPageError(error: Throwable) { | ||||
|         adapter?.onLoadMoreComplete(null) | ||||
|         adapter?.endlessTargetCount = 1 | ||||
|         logcat(LogPriority.ERROR, error) | ||||
|     } | ||||
|  | ||||
|     override fun onUpdateEmptyView(size: Int) { | ||||
|         if (size > 0) { | ||||
|             binding.emptyView.hide() | ||||
|         } else { | ||||
|             binding.emptyView.show(R.string.information_no_recent_manga) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets a new progress item and reenables the scroll listener. | ||||
|      */ | ||||
|     private fun resetProgressItem() { | ||||
|         progressItem = ProgressItem() | ||||
|         adapter?.endlessTargetCount = 0 | ||||
|         adapter?.setEndlessScrollListener(this, progressItem!!) | ||||
|     } | ||||
|  | ||||
|     override fun onLoadMore(lastPosition: Int, currentPage: Int) { | ||||
|         val view = view ?: return | ||||
|         if (BackupRestoreService.isRunning(view.context.applicationContext)) { | ||||
|             onAddPageError(Throwable()) | ||||
|             return | ||||
|         } | ||||
|         val adapter = adapter ?: return | ||||
|         presenter.requestNext(adapter.itemCount - adapter.headerItems.size, query) | ||||
|     } | ||||
|  | ||||
|     override fun noMoreLoad(newItemsSize: Int) {} | ||||
|  | ||||
|     override fun onResumeClick(position: Int) { | ||||
|         val activity = activity ?: return | ||||
|         val (manga, chapter, _) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return | ||||
|  | ||||
|         val nextChapter = presenter.getNextChapter(chapter, manga) | ||||
|         if (nextChapter != null) { | ||||
|             val intent = ReaderActivity.newIntent(activity, manga, nextChapter) | ||||
|             startActivity(intent) | ||||
|         } else { | ||||
|             activity.toast(R.string.no_next_chapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onRemoveClick(position: Int) { | ||||
|         val (manga, _, history) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return | ||||
|         RemoveHistoryDialog(this, manga, history).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun onItemClick(position: Int) { | ||||
|         val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return | ||||
|         router.pushController(MangaController(manga).withFadeTransaction()) | ||||
|     } | ||||
|  | ||||
|     override fun removeHistory(manga: Manga, history: History, all: Boolean) { | ||||
|         if (all) { | ||||
|             // Reset last read of chapter to 0L | ||||
|             presenter.removeAllFromHistory(manga.id!!) | ||||
|         } else { | ||||
|             // Remove all chapters belonging to manga from library | ||||
|             presenter.removeFromHistory(history) | ||||
|         } | ||||
|     @Composable | ||||
|     override fun ComposeContent() { | ||||
|         HistoryScreen( | ||||
|             composeView = binding.root, | ||||
|             presenter = presenter, | ||||
|             onClickItem = { history -> | ||||
|                 router.pushController(MangaController(history).withFadeTransaction()) | ||||
|             }, | ||||
|             onClickResume = { history -> | ||||
|                 presenter.getNextChapterForManga(history.mangaId, history.chapterId) | ||||
|             }, | ||||
|             onClickDelete = { history, all -> | ||||
|                 if (all) { | ||||
|                     // Reset last read of chapter to 0L | ||||
|                     presenter.removeAllFromHistory(history.mangaId) | ||||
|                 } else { | ||||
|                     // Remove all chapters belonging to manga from library | ||||
|                     presenter.removeFromHistory(history) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
| @@ -201,46 +61,33 @@ class HistoryController : | ||||
|             searchView.clearFocus() | ||||
|         } | ||||
|         searchView.queryTextChanges() | ||||
|             .drop(1) // Drop first event after subscribed | ||||
|             .filter { router.backstack.lastOrNull()?.controller == this } | ||||
|             .onEach { | ||||
|                 query = it.toString() | ||||
|                 presenter.updateList(query) | ||||
|                 presenter.search(query) | ||||
|             } | ||||
|             .launchIn(viewScope) | ||||
|  | ||||
|         // Fixes problem with the overflow icon showing up in lieu of search | ||||
|         searchItem.fixExpand( | ||||
|             onExpand = { invalidateMenuOnExpand() }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|         return when (item.itemId) { | ||||
|             R.id.action_clear_history -> { | ||||
|                 val ctrl = ClearHistoryDialogController() | ||||
|                 ctrl.targetController = this@HistoryController | ||||
|                 ctrl.showDialog(router) | ||||
|                 val dialog = ClearHistoryDialogController() | ||||
|                 dialog.targetController = this@HistoryController | ||||
|                 dialog.showDialog(router) | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return super.onOptionsItemSelected(item) | ||||
|     } | ||||
|  | ||||
|     class ClearHistoryDialogController : DialogController() { | ||||
|         override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|             return MaterialAlertDialogBuilder(activity!!) | ||||
|                 .setMessage(R.string.clear_history_confirmation) | ||||
|                 .setPositiveButton(android.R.string.ok) { _, _ -> | ||||
|                     (targetController as? HistoryController)?.clearHistory() | ||||
|                 } | ||||
|                 .setNegativeButton(android.R.string.cancel, null) | ||||
|                 .create() | ||||
|             else -> super.onOptionsItemSelected(item) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun clearHistory() { | ||||
|         db.deleteHistory().executeAsBlocking() | ||||
|         activity?.toast(R.string.clear_history_completed) | ||||
|     fun openChapter(chapter: Chapter?) { | ||||
|         val activity = activity ?: return | ||||
|         if (chapter != null) { | ||||
|             val intent = ReaderActivity.newIntent(activity, chapter.mangaId, chapter.id) | ||||
|             startActivity(intent) | ||||
|         } else { | ||||
|             activity.toast(R.string.no_next_chapter) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,71 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.view.View | ||||
| import coil.dispose | ||||
| import coil.load | ||||
| import eu.davidea.viewholders.FlexibleViewHolder | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory | ||||
| import eu.kanade.tachiyomi.databinding.HistoryItemBinding | ||||
| import eu.kanade.tachiyomi.util.lang.toTimestampString | ||||
| import java.util.Date | ||||
|  | ||||
| /** | ||||
|  * Holder that contains recent manga item | ||||
|  * Uses R.layout.item_recently_read. | ||||
|  * UI related actions should be called from here. | ||||
|  * | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @constructor creates a new recent chapter holder. | ||||
|  */ | ||||
| class HistoryHolder( | ||||
|     view: View, | ||||
|     val adapter: HistoryAdapter, | ||||
| ) : FlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     private val binding = HistoryItemBinding.bind(view) | ||||
|  | ||||
|     init { | ||||
|         binding.holder.setOnClickListener { | ||||
|             adapter.itemClickListener.onItemClick(bindingAdapterPosition) | ||||
|         } | ||||
|  | ||||
|         binding.remove.setOnClickListener { | ||||
|             adapter.removeClickListener.onRemoveClick(bindingAdapterPosition) | ||||
|         } | ||||
|  | ||||
|         binding.resume.setOnClickListener { | ||||
|             adapter.resumeClickListener.onResumeClick(bindingAdapterPosition) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set values of view | ||||
|      * | ||||
|      * @param item item containing history information | ||||
|      */ | ||||
|     fun bind(item: MangaChapterHistory) { | ||||
|         // Retrieve objects | ||||
|         val (manga, chapter, history) = item | ||||
|  | ||||
|         // Set manga title | ||||
|         binding.mangaTitle.text = manga.title | ||||
|  | ||||
|         // Set chapter number + timestamp | ||||
|         if (chapter.chapter_number > -1f) { | ||||
|             val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) | ||||
|             binding.mangaSubtitle.text = itemView.context.getString( | ||||
|                 R.string.recent_manga_time, | ||||
|                 formattedNumber, | ||||
|                 Date(history.last_read).toTimestampString(), | ||||
|             ) | ||||
|         } else { | ||||
|             binding.mangaSubtitle.text = Date(history.last_read).toTimestampString() | ||||
|         } | ||||
|  | ||||
|         // Set cover | ||||
|         binding.cover.dispose() | ||||
|         binding.cover.load(item.manga) | ||||
|     } | ||||
| } | ||||
| @@ -1,42 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.view.View | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractSectionableItem | ||||
| import eu.davidea.flexibleadapter.items.IFlexible | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory | ||||
| import eu.kanade.tachiyomi.ui.recent.DateSectionItem | ||||
|  | ||||
| class HistoryItem(val mch: MangaChapterHistory, header: DateSectionItem) : | ||||
|     AbstractSectionableItem<HistoryHolder, DateSectionItem>(header) { | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.history_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): HistoryHolder { | ||||
|         return HistoryHolder(view, adapter as HistoryAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder( | ||||
|         adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, | ||||
|         holder: HistoryHolder, | ||||
|         position: Int, | ||||
|         payloads: List<Any?>?, | ||||
|     ) { | ||||
|         holder.bind(mch) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other is HistoryItem) { | ||||
|             return mch.manga.id == other.mch.manga.id | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return mch.manga.id!!.hashCode() | ||||
|     } | ||||
| } | ||||
| @@ -1,157 +1,127 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import androidx.paging.PagingData | ||||
| import androidx.paging.cachedIn | ||||
| import androidx.paging.insertSeparators | ||||
| import androidx.paging.map | ||||
| import eu.kanade.domain.history.interactor.DeleteHistoryTable | ||||
| import eu.kanade.domain.history.interactor.GetHistory | ||||
| import eu.kanade.domain.history.interactor.GetNextChapterForManga | ||||
| import eu.kanade.domain.history.interactor.RemoveHistoryById | ||||
| import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId | ||||
| import eu.kanade.domain.history.model.HistoryWithRelations | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.ui.recent.DateSectionItem | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.lang.launchUI | ||||
| import eu.kanade.tachiyomi.util.lang.toDateKey | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.DateFormat | ||||
| import java.util.Calendar | ||||
| import java.util.Date | ||||
| import java.util.TreeMap | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.flatMapLatest | ||||
| import kotlinx.coroutines.flow.map | ||||
| import kotlinx.coroutines.flow.update | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Presenter of HistoryFragment. | ||||
|  * Contains information and data for fragment. | ||||
|  * Observable updates should be called from here. | ||||
|  */ | ||||
| class HistoryPresenter : BasePresenter<HistoryController>() { | ||||
| class HistoryPresenter( | ||||
|     private val getHistory: GetHistory = Injekt.get(), | ||||
|     private val getNextChapterForManga: GetNextChapterForManga = Injekt.get(), | ||||
|     private val deleteHistoryTable: DeleteHistoryTable = Injekt.get(), | ||||
|     private val removeHistoryById: RemoveHistoryById = Injekt.get(), | ||||
|     private val removeHistoryByMangaId: RemoveHistoryByMangaId = Injekt.get(), | ||||
| ) : BasePresenter<HistoryController>() { | ||||
|  | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private val relativeTime: Int = preferences.relativeTime().get() | ||||
|     private val dateFormat: DateFormat = preferences.dateFormat() | ||||
|  | ||||
|     private var recentMangaSubscription: Subscription? = null | ||||
|     private var _query: MutableStateFlow<String> = MutableStateFlow("") | ||||
|     private var _state: MutableStateFlow<HistoryState> = MutableStateFlow(HistoryState.EMPTY) | ||||
|     val state: StateFlow<HistoryState> = _state | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|  | ||||
|         // Used to get a list of recently read manga | ||||
|         updateList() | ||||
|     } | ||||
|  | ||||
|     fun requestNext(offset: Int, search: String = "") { | ||||
|         getRecentMangaObservable(offset = offset, search = search) | ||||
|             .subscribeLatestCache( | ||||
|                 { view, mangas -> | ||||
|                     view.onNextManga(mangas) | ||||
|                 }, | ||||
|                 HistoryController::onAddPageError, | ||||
|             ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get recent manga observable | ||||
|      * @return list of history | ||||
|      */ | ||||
|     private fun getRecentMangaObservable(limit: Int = 25, offset: Int = 0, search: String = ""): Observable<List<HistoryItem>> { | ||||
|         // Set date limit for recent manga | ||||
|         val cal = Calendar.getInstance().apply { | ||||
|             time = Date() | ||||
|             add(Calendar.YEAR, -50) | ||||
|         } | ||||
|  | ||||
|         return db.getRecentManga(cal.time, limit, offset, search).asRxObservable() | ||||
|             .map { recents -> | ||||
|                 val map = TreeMap<Date, MutableList<MangaChapterHistory>> { d1, d2 -> d2.compareTo(d1) } | ||||
|                 val byDay = recents | ||||
|                     .groupByTo(map) { it.history.last_read.toDateKey() } | ||||
|                 byDay.flatMap { entry -> | ||||
|                     val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat) | ||||
|                     entry.value.map { HistoryItem(it, dateItem) } | ||||
|                 } | ||||
|             } | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reset last read of chapter to 0L | ||||
|      * @param history history belonging to chapter | ||||
|      */ | ||||
|     fun removeFromHistory(history: History) { | ||||
|         history.last_read = 0L | ||||
|         db.updateHistoryLastRead(history).asRxObservable() | ||||
|             .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Pull a list of history from the db | ||||
|      * @param search a search query to use for filtering | ||||
|      */ | ||||
|     fun updateList(search: String = "") { | ||||
|         recentMangaSubscription?.unsubscribe() | ||||
|         recentMangaSubscription = getRecentMangaObservable(search = search) | ||||
|             .subscribeLatestCache( | ||||
|                 { view, mangas -> | ||||
|                     view.onNextManga(mangas, true) | ||||
|                 }, | ||||
|                 HistoryController::onAddPageError, | ||||
|             ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Removes all chapters belonging to manga from history. | ||||
|      * @param mangaId id of manga | ||||
|      */ | ||||
|     fun removeAllFromHistory(mangaId: Long) { | ||||
|         db.getHistoryByMangaId(mangaId).asRxSingle() | ||||
|             .map { list -> | ||||
|                 list.forEach { it.last_read = 0L } | ||||
|                 db.updateHistoryLastRead(list).executeAsBlocking() | ||||
|             } | ||||
|             .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Retrieves the next chapter of the given one. | ||||
|      * | ||||
|      * @param chapter the chapter of the history object. | ||||
|      * @param manga the manga of the chapter. | ||||
|      */ | ||||
|     fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? { | ||||
|         if (!chapter.read) { | ||||
|             return chapter | ||||
|         } | ||||
|  | ||||
|         val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { | ||||
|             Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } | ||||
|             Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } | ||||
|             Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) } | ||||
|             else -> throw NotImplementedError("Unknown sorting method") | ||||
|         } | ||||
|  | ||||
|         val chapters = db.getChapters(manga).executeAsBlocking() | ||||
|             .sortedWith { c1, c2 -> sortFunction(c1, c2) } | ||||
|  | ||||
|         val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } | ||||
|         return when (manga.sorting) { | ||||
|             Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1) | ||||
|             Manga.CHAPTER_SORTING_NUMBER -> { | ||||
|                 val chapterNumber = chapter.chapter_number | ||||
|  | ||||
|                 ((currChapterIndex + 1) until chapters.size) | ||||
|                     .map { chapters[it] } | ||||
|                     .firstOrNull { | ||||
|                         it.chapter_number > chapterNumber && | ||||
|                             it.chapter_number <= chapterNumber + 1 | ||||
|         presenterScope.launchIO { | ||||
|             _state.update { state -> | ||||
|                 state.copy( | ||||
|                     list = _query.flatMapLatest { query -> | ||||
|                         getHistory.subscribe(query) | ||||
|                             .map { pagingData -> | ||||
|                                 pagingData | ||||
|                                     .map { | ||||
|                                         UiModel.Item(it) | ||||
|                                     } | ||||
|                                     .insertSeparators { before, after -> | ||||
|                                         val beforeDate = before?.item?.readAt?.time?.toDateKey() ?: Date(0) | ||||
|                                         val afterDate = after?.item?.readAt?.time?.toDateKey() ?: Date(0) | ||||
|                                         when { | ||||
|                                             beforeDate.time != afterDate.time && afterDate.time != 0L -> UiModel.Header(afterDate) | ||||
|                                             // Return null to avoid adding a separator between two items. | ||||
|                                             else -> null | ||||
|                                         } | ||||
|                                     } | ||||
|                             } | ||||
|                     } | ||||
|                         .cachedIn(presenterScope), | ||||
|                 ) | ||||
|             } | ||||
|             Manga.CHAPTER_SORTING_UPLOAD_DATE -> { | ||||
|                 chapters.drop(currChapterIndex + 1) | ||||
|                     .firstOrNull { it.date_upload >= chapter.date_upload } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun search(query: String) { | ||||
|         presenterScope.launchIO { | ||||
|             _query.emit(query) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun removeFromHistory(history: HistoryWithRelations) { | ||||
|         presenterScope.launchIO { | ||||
|             removeHistoryById.await(history) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun removeAllFromHistory(mangaId: Long) { | ||||
|         presenterScope.launchIO { | ||||
|             removeHistoryByMangaId.await(mangaId) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getNextChapterForManga(mangaId: Long, chapterId: Long) { | ||||
|         presenterScope.launchIO { | ||||
|             val chapter = getNextChapterForManga.await(mangaId, chapterId) | ||||
|             launchUI { | ||||
|                 view?.openChapter(chapter) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun deleteAllHistory() { | ||||
|         presenterScope.launchIO { | ||||
|             val result = deleteHistoryTable.await() | ||||
|             if (!result) return@launchIO | ||||
|             launchUI { | ||||
|                 view?.activity?.toast(R.string.clear_history_completed) | ||||
|             } | ||||
|             else -> throw NotImplementedError("Unknown sorting method") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| sealed class UiModel { | ||||
|     data class Item(val item: HistoryWithRelations) : UiModel() | ||||
|     data class Header(val date: Date) : UiModel() | ||||
| } | ||||
|  | ||||
| data class HistoryState( | ||||
|     val list: Flow<PagingData<UiModel>>? = null, | ||||
| ) { | ||||
|  | ||||
|     companion object { | ||||
|         val EMPTY = HistoryState(null) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,54 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.history | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.widget.DialogCheckboxView | ||||
|  | ||||
| class RemoveHistoryDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : RemoveHistoryDialog.Listener { | ||||
|  | ||||
|     private var manga: Manga? = null | ||||
|  | ||||
|     private var history: History? = null | ||||
|  | ||||
|     constructor(target: T, manga: Manga, history: History) : this() { | ||||
|         this.manga = manga | ||||
|         this.history = history | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|  | ||||
|         // Create custom view | ||||
|         val dialogCheckboxView = DialogCheckboxView(activity).apply { | ||||
|             setDescription(R.string.dialog_with_checkbox_remove_description) | ||||
|             setOptionDescription(R.string.dialog_with_checkbox_reset) | ||||
|         } | ||||
|  | ||||
|         return MaterialAlertDialogBuilder(activity) | ||||
|             .setTitle(R.string.action_remove) | ||||
|             .setView(dialogCheckboxView) | ||||
|             .setPositiveButton(R.string.action_remove) { _, _ -> onPositive(dialogCheckboxView.isChecked()) } | ||||
|             .setNegativeButton(android.R.string.cancel, null) | ||||
|             .create() | ||||
|     } | ||||
|  | ||||
|     private fun onPositive(checked: Boolean) { | ||||
|         val target = targetController as? Listener ?: return | ||||
|         val manga = manga ?: return | ||||
|         val history = history ?: return | ||||
|  | ||||
|         target.removeHistory(manga, history, checked) | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun removeHistory(manga: Manga, history: History, all: Boolean) | ||||
|     } | ||||
| } | ||||
| @@ -9,7 +9,6 @@ import android.view.MenuInflater | ||||
| import android.view.MenuItem | ||||
| import android.view.View | ||||
| import androidx.core.view.forEach | ||||
| import androidx.core.view.get | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| @@ -37,7 +36,6 @@ class ClearDatabaseController : | ||||
|     private var menu: Menu? = null | ||||
|  | ||||
|     private var actionFab: ExtendedFloatingActionButton? = null | ||||
|     private var actionFabScrollListener: RecyclerView.OnScrollListener? = null | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
| @@ -143,7 +141,6 @@ class ClearDatabaseController : | ||||
|  | ||||
|     override fun cleanupFab(fab: ExtendedFloatingActionButton) { | ||||
|         actionFab?.setOnClickListener(null) | ||||
|         actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) } | ||||
|         actionFab = null | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.ui.setting.database | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.Database | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| @@ -13,6 +14,7 @@ import uy.kohesive.injekt.api.get | ||||
| class ClearDatabasePresenter : BasePresenter<ClearDatabaseController>() { | ||||
|  | ||||
|     private val db = Injekt.get<DatabaseHelper>() | ||||
|     private val database = Injekt.get<Database>() | ||||
|  | ||||
|     private val sourceManager = Injekt.get<SourceManager>() | ||||
|  | ||||
| @@ -26,7 +28,7 @@ class ClearDatabasePresenter : BasePresenter<ClearDatabaseController>() { | ||||
|  | ||||
|     fun clearDatabaseForSourceIds(sources: List<Long>) { | ||||
|         db.deleteMangasNotInLibraryBySourceIds(sources).executeAsBlocking() | ||||
|         db.deleteHistoryNoLastRead().executeAsBlocking() | ||||
|         database.historyQueries.removeResettedHistory() | ||||
|     } | ||||
|  | ||||
|     private fun getDatabaseSourcesObservable(): Observable<List<ClearDatabaseSourceItem>> { | ||||
|   | ||||
| @@ -5,8 +5,10 @@ import android.os.Parcel | ||||
| import android.os.Parcelable | ||||
| import android.util.AttributeSet | ||||
| import android.view.View | ||||
| import androidx.compose.ui.platform.ComposeView | ||||
| import androidx.coordinatorlayout.R | ||||
| import androidx.coordinatorlayout.widget.CoordinatorLayout | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.doOnLayout | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.customview.view.AbsSavedState | ||||
| @@ -63,7 +65,16 @@ class TachiyomiCoordinatorLayout @JvmOverloads constructor( | ||||
|         super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed) | ||||
|         // Disable elevation overlay when tabs are visible | ||||
|         if (canLiftAppBarOnScroll) { | ||||
|             appBarLayout?.isLifted = (dyConsumed != 0 || dyUnconsumed >= 0) && tabLayout?.isVisible == false | ||||
|             if (target is ComposeView) { | ||||
|                 val scrollCondition = if (type == ViewCompat.TYPE_NON_TOUCH) { | ||||
|                     dyUnconsumed >= 0 | ||||
|                 } else { | ||||
|                     dyConsumed != 0 || dyUnconsumed >= 0 | ||||
|                 } | ||||
|                 appBarLayout?.isLifted = scrollCondition && tabLayout?.isVisible == false | ||||
|             } else { | ||||
|                 appBarLayout?.isLifted = (dyConsumed != 0 || dyUnconsumed >= 0) && tabLayout?.isVisible == false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -25,7 +25,7 @@ class ThemesPreferenceAdapter(private val clickListener: OnItemClickListener) : | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemeViewHolder { | ||||
|         val themeResIds = ThemingDelegate.getThemeResIds(themes[viewType], preferences.themeDarkAmoled().get()) | ||||
|         val themedContext = themeResIds.fold(parent.context) { | ||||
|                 context, themeResId -> | ||||
|             context, themeResId -> | ||||
|             ContextThemeWrapper(context, themeResId) | ||||
|         } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user