refactor: history entity

* Added HistoryWithBook that fetches both HistoryEntity and BookEntity
* Removed nullability from History
This commit is contained in:
Acclorite 2025-08-29 13:51:39 +03:00
parent a45078cae5
commit 56b5aba67a
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
14 changed files with 106 additions and 73 deletions

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.local.dto
import androidx.room.Embedded
import androidx.room.Relation
data class HistoryWithBook(
@Embedded val history: HistoryEntity,
@Relation(
parentColumn = "bookId",
entityColumn = "id"
)
val book: BookEntity
)

View file

@ -16,7 +16,6 @@ import androidx.room.Upsert
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.dto.CategoryEntity import ua.acclorite.book_story.data.local.dto.CategoryEntity
import ua.acclorite.book_story.data.local.dto.CategorySortEntity import ua.acclorite.book_story.data.local.dto.CategorySortEntity
import ua.acclorite.book_story.data.local.dto.HistoryEntity
@Dao @Dao
interface BookDao { interface BookDao {
@ -46,29 +45,6 @@ interface BookDao {
/* - - - - - - - - - - - - - - - - - - - - - - */ /* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ HistoryEntity --------------------- */
@Query("SELECT * FROM historyentity")
suspend fun getHistory(): List<HistoryEntity>
@Query("SELECT * FROM historyentity WHERE bookId = :bookId ORDER BY time DESC LIMIT 1")
fun getHistoryForBook(bookId: Int): HistoryEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHistory(
history: HistoryEntity
)
@Query("DELETE FROM historyentity")
suspend fun deleteWholeHistory(): Int
@Query("DELETE FROM historyentity WHERE bookId = :bookId")
suspend fun deleteHistoryForBook(bookId: Int): Int
@Delete
suspend fun deleteHistory(history: HistoryEntity): Int
/* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ CategoryEntity ----------------- */ /* ------ CategoryEntity ----------------- */
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCategory( suspend fun insertCategory(

View file

@ -50,6 +50,7 @@ import java.io.File
) )
abstract class BookDatabase : RoomDatabase() { abstract class BookDatabase : RoomDatabase() {
abstract val bookDao: BookDao abstract val bookDao: BookDao
abstract val historyDao: HistoryDao
abstract val colorPresetDao: ColorPresetDao abstract val colorPresetDao: ColorPresetDao
} }

View file

@ -0,0 +1,41 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.local.room
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import ua.acclorite.book_story.data.local.dto.HistoryEntity
import ua.acclorite.book_story.data.local.dto.HistoryWithBook
@Dao
interface HistoryDao {
@Transaction
@Query("SELECT * FROM historyentity")
suspend fun getHistoryWithBook(): List<HistoryWithBook>
@Transaction
@Query("SELECT * FROM historyentity WHERE bookId = :bookId ORDER BY time DESC LIMIT 1")
suspend fun getHistoryForBook(bookId: Int): HistoryWithBook?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHistory(
history: HistoryEntity
)
@Query("DELETE FROM historyentity")
suspend fun deleteWholeHistory(): Int
@Query("DELETE FROM historyentity WHERE bookId = :bookId")
suspend fun deleteHistoryForBook(bookId: Int): Int
@Delete
suspend fun deleteHistory(history: HistoryEntity): Int
}

View file

@ -7,10 +7,10 @@
package ua.acclorite.book_story.data.mapper.history package ua.acclorite.book_story.data.mapper.history
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
import ua.acclorite.book_story.data.local.dto.HistoryWithBook
import ua.acclorite.book_story.domain.model.history.History import ua.acclorite.book_story.domain.model.history.History
interface HistoryMapper { interface HistoryMapper {
suspend fun toHistoryEntity(history: History): HistoryEntity suspend fun toHistoryEntity(history: History): HistoryEntity
suspend fun toHistory(historyWithBook: HistoryWithBook): History
suspend fun toHistory(historyEntity: HistoryEntity): History
} }

View file

@ -7,24 +7,27 @@
package ua.acclorite.book_story.data.mapper.history package ua.acclorite.book_story.data.mapper.history
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
import ua.acclorite.book_story.data.local.dto.HistoryWithBook
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.domain.model.history.History import ua.acclorite.book_story.domain.model.history.History
import javax.inject.Inject import javax.inject.Inject
class HistoryMapperImpl @Inject constructor() : HistoryMapper { class HistoryMapperImpl @Inject constructor(
private val bookMapper: BookMapper
) : HistoryMapper {
override suspend fun toHistoryEntity(history: History): HistoryEntity { override suspend fun toHistoryEntity(history: History): HistoryEntity {
return HistoryEntity( return HistoryEntity(
id = history.id, id = history.id,
bookId = history.bookId, bookId = history.book.id,
time = history.time time = history.time
) )
} }
override suspend fun toHistory(historyEntity: HistoryEntity): History { override suspend fun toHistory(historyWithBook: HistoryWithBook): History {
return History( return History(
historyEntity.id, id = historyWithBook.history.id,
bookId = historyEntity.bookId, book = bookMapper.toBook(historyWithBook.book),
book = null, time = historyWithBook.history.time
time = historyEntity.time
) )
} }
} }

View file

@ -23,7 +23,7 @@ class HistoryRepositoryImpl @Inject constructor(
override suspend fun getHistoryForBook(bookId: Int): Result<History> = runCatching { override suspend fun getHistoryForBook(bookId: Int): Result<History> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.getHistoryForBook(bookId).let { database.historyDao.getHistoryForBook(bookId).let {
if (it == null) throw NoSuchElementException("Could not get history from [$bookId].") if (it == null) throw NoSuchElementException("Could not get history from [$bookId].")
else historyMapper.toHistory(it) else historyMapper.toHistory(it)
} }
@ -32,19 +32,19 @@ class HistoryRepositoryImpl @Inject constructor(
override suspend fun addHistory(history: History): Result<Unit> = runCatching { override suspend fun addHistory(history: History): Result<Unit> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.insertHistory(historyMapper.toHistoryEntity(history)) database.historyDao.insertHistory(historyMapper.toHistoryEntity(history))
} }
} }
override suspend fun getHistory(): Result<List<History>> = runCatching { override suspend fun getHistory(): Result<List<History>> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.getHistory().map { historyMapper.toHistory(it) } database.historyDao.getHistoryWithBook().map { historyMapper.toHistory(it) }
} }
} }
override suspend fun deleteWholeHistory(): Result<Unit> = runCatching { override suspend fun deleteWholeHistory(): Result<Unit> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.deleteWholeHistory().also { database.historyDao.deleteWholeHistory().also {
if (it == 0) throw Exception("Could not delete whole history in database.") if (it == 0) throw Exception("Could not delete whole history in database.")
} }
} }
@ -52,7 +52,7 @@ class HistoryRepositoryImpl @Inject constructor(
override suspend fun deleteHistoryForBook(bookId: Int): Result<Unit> = runCatching { override suspend fun deleteHistoryForBook(bookId: Int): Result<Unit> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.deleteHistoryForBook(bookId = bookId).also { database.historyDao.deleteHistoryForBook(bookId = bookId).also {
if (it == 0) throw Exception("Could not delete history for book [$bookId] in database.") if (it == 0) throw Exception("Could not delete history for book [$bookId] in database.")
} }
} }
@ -60,7 +60,7 @@ class HistoryRepositoryImpl @Inject constructor(
override suspend fun deleteHistory(history: History): Result<Unit> = runCatching { override suspend fun deleteHistory(history: History): Result<Unit> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.bookDao.deleteHistory(historyMapper.toHistoryEntity(history)).also { database.historyDao.deleteHistory(historyMapper.toHistoryEntity(history)).also {
if (it == 0) throw Exception("Could not delete history in database.") if (it == 0) throw Exception("Could not delete history in database.")
} }
} }

View file

@ -11,8 +11,7 @@ import ua.acclorite.book_story.domain.model.library.Book
@Immutable @Immutable
data class History( data class History(
val id: Int = 0, val id: Int,
val bookId: Int, val book: Book,
val book: Book?,
val time: Long val time: Long
) )

View file

@ -17,14 +17,14 @@ class AddHistoryUseCase @Inject constructor(
) { ) {
suspend operator fun invoke(history: History) { suspend operator fun invoke(history: History) {
logI("Inserting history for [${history.bookId}].") logI("Inserting history for [${history.book.id}].")
historyRepository.addHistory(history = history).fold( historyRepository.addHistory(history = history).fold(
onSuccess = { onSuccess = {
logI("Successfully inserted history for [${history.bookId}].") logI("Successfully inserted history for [${history.book.id}].")
}, },
onFailure = { onFailure = {
logE("Could not insert history for [${history.bookId}] with error: ${it.message}") logE("Could not insert history for [${history.book.id}] with error: ${it.message}")
} }
) )
} }

View file

@ -9,7 +9,6 @@ package ua.acclorite.book_story.domain.use_case.history
import ua.acclorite.book_story.core.log.logE import ua.acclorite.book_story.core.log.logE
import ua.acclorite.book_story.core.log.logI import ua.acclorite.book_story.core.log.logI
import ua.acclorite.book_story.domain.model.history.History import ua.acclorite.book_story.domain.model.history.History
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.repository.HistoryRepository import ua.acclorite.book_story.domain.repository.HistoryRepository
import ua.acclorite.book_story.presentation.history.model.GroupedHistory import ua.acclorite.book_story.presentation.history.model.GroupedHistory
import java.time.Instant import java.time.Instant
@ -19,7 +18,6 @@ import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
class GetHistoryUseCase @Inject constructor( class GetHistoryUseCase @Inject constructor(
private val bookRepository: BookRepository,
private val historyRepository: HistoryRepository private val historyRepository: HistoryRepository
) { ) {
@ -40,27 +38,19 @@ class GetHistoryUseCase @Inject constructor(
} }
fun filterMaxElementsById(elements: List<History>): List<History> { fun filterMaxElementsById(elements: List<History>): List<History> {
val groupedById = elements.groupBy { it.bookId } val groupedById = elements.groupBy { it.book.id }
val maxElementsById = groupedById.map { (_, values) -> val maxElementsById = groupedById.map { (_, values) ->
values.maxByOrNull { it.time } values.maxByOrNull { it.time }
} }
return maxElementsById.filterNotNull() return maxElementsById.filterNotNull()
} }
val query = query.lowercase().trim()
return runCatching { return runCatching {
historyRepository.getHistory().getOrThrow().sortedByDescending { it.time } historyRepository.getHistory().getOrThrow()
.mapNotNull { history -> .filter { history -> history.book.title.lowercase().trim().contains(query) }
val book = bookRepository.getBook(history.bookId).getOrNull() .sortedByDescending { history -> history.time }
if ( .groupBy { history -> getDayLabel(history.time) }
book == null
|| !book.title.lowercase().trim().contains(query.lowercase().trim())
) return@mapNotNull null
history.copy(book = book)
}
.groupBy { history ->
getDayLabel(history.time)
}
.map { (day, history) -> GroupedHistory(day, filterMaxElementsById(history)) } .map { (day, history) -> GroupedHistory(day, filterMaxElementsById(history)) }
}.fold( }.fold(
onSuccess = { onSuccess = {

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.domain.model.history.History import ua.acclorite.book_story.domain.model.history.History
import ua.acclorite.book_story.domain.use_case.book.GetBookUseCase
import ua.acclorite.book_story.domain.use_case.history.AddHistoryUseCase import ua.acclorite.book_story.domain.use_case.history.AddHistoryUseCase
import ua.acclorite.book_story.domain.use_case.history.DeleteHistoryUseCase import ua.acclorite.book_story.domain.use_case.history.DeleteHistoryUseCase
import ua.acclorite.book_story.domain.use_case.history.DeleteWholeHistoryUseCase import ua.acclorite.book_story.domain.use_case.history.DeleteWholeHistoryUseCase
@ -38,7 +39,8 @@ class HistoryModel @Inject constructor(
private val getHistoryUseCase: GetHistoryUseCase, private val getHistoryUseCase: GetHistoryUseCase,
private val addHistoryUseCase: AddHistoryUseCase, private val addHistoryUseCase: AddHistoryUseCase,
private val deleteHistoryUseCase: DeleteHistoryUseCase, private val deleteHistoryUseCase: DeleteHistoryUseCase,
private val deleteWholeHistoryUseCase: DeleteWholeHistoryUseCase private val deleteWholeHistoryUseCase: DeleteWholeHistoryUseCase,
private val getBookUseCase: GetBookUseCase
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex() private val mutex = Mutex()
@ -72,13 +74,15 @@ class HistoryModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
HistoryScreen.insertHistoryChannel.receiveAsFlow().collectLatest { bookId -> HistoryScreen.insertHistoryChannel.receiveAsFlow().collectLatest { bookId ->
addHistoryUseCase( getBookUseCase(bookId)?.let { book ->
History( addHistoryUseCase(
bookId = bookId, History(
book = null, id = 0,
time = Date().time book = book,
time = Date().time
)
) )
) }
delay(500) delay(500)

View file

@ -79,7 +79,7 @@ fun LazyItemScope.HistoryItem(
RoundedCornerShape(10.dp) RoundedCornerShape(10.dp)
) )
) { ) {
if (historyEntry.book?.coverImage != null) { if (historyEntry.book.coverImage != null) {
AsyncCoverImage( AsyncCoverImage(
uri = historyEntry.book.coverImage, uri = historyEntry.book.coverImage,
modifier = Modifier modifier = Modifier
@ -108,7 +108,7 @@ fun LazyItemScope.HistoryItem(
modifier = Modifier.fillMaxHeight() modifier = Modifier.fillMaxHeight()
) { ) {
StyledText( StyledText(
text = historyEntry.book?.title ?: return, text = historyEntry.book.title,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.noRippleClickable( .noRippleClickable(

View file

@ -72,14 +72,14 @@ fun HistoryLayout(
onBodyClick = { onBodyClick = {
navigateToBookInfo( navigateToBookInfo(
HistoryEvent.OnNavigateToBookInfo( HistoryEvent.OnNavigateToBookInfo(
historyEntry.bookId historyEntry.book.id
) )
) )
}, },
onTitleClick = { onTitleClick = {
navigateToReader( navigateToReader(
HistoryEvent.OnNavigateToReader( HistoryEvent.OnNavigateToReader(
historyEntry.bookId historyEntry.book.id
) )
) )
}, },