feat(remote-library): add ebook opening from RemoteLibrary tab
- Add CacheRepository + CacheBookUseCase to download/cache remote ebooks via Audiobookshelf and upsert a local BookEntity/EbookFileEntity. - Support plain local file paths in FileProviderImpl and CachedFile.canAccess so reader can open downloaded ebooks stored in app filesDir. - Add Read button to RemoteLibraryContent for books with hasEbook=true. - Wire RemoteLibraryModel/Screen to cache the ebook and navigate to ReaderScreen with the local book id. - Add string resource for the Read button.
This commit is contained in:
parent
ed3afc1f16
commit
d1257e1d60
11 changed files with 247 additions and 387 deletions
|
|
@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
|||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
||||
|
|
@ -96,6 +97,12 @@ abstract class RepositoryModule {
|
|||
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
||||
): RemoteLibraryRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCacheRepository(
|
||||
cacheRepositoryImpl: CacheRepositoryImpl
|
||||
): CacheRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBookMapper(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ class CachedFile(
|
|||
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
|
||||
|
||||
fun canAccess(): Boolean {
|
||||
if (uri.scheme == "file") {
|
||||
val file = File(uri.path ?: return false)
|
||||
return file.exists() && file.canRead()
|
||||
}
|
||||
|
||||
return try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.let {
|
||||
it.close()
|
||||
|
|
|
|||
|
|
@ -7,27 +7,14 @@
|
|||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import android.webkit.URLUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.local.dto.BookEntity
|
||||
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
|
||||
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.data.worker.CacheDownloadWorker
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||
import java.io.File
|
||||
|
|
@ -38,194 +25,98 @@ import javax.inject.Singleton
|
|||
class CacheRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
private val apiFactory: AudiobookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
private val workManager: WorkManager,
|
||||
private val application: Application,
|
||||
) : CacheRepository {
|
||||
override suspend fun cacheBook(book: Book): Result<Unit> = runCatching {
|
||||
|
||||
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val localBook = findOrInsertBook(book)
|
||||
val bookId = localBook.id
|
||||
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
|
||||
require(book.hasEbook) { "Book has no ebook." }
|
||||
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||
|
||||
if (book.coverUrl.isNotBlank()) {
|
||||
createCacheRecord(bookId, "cover", book.coverUrl)
|
||||
}
|
||||
if (book.hasEbook) {
|
||||
createCacheRecord(
|
||||
bookId,
|
||||
"ebook",
|
||||
"$absUrl/api/items/${book.remoteId}/download",
|
||||
)
|
||||
}
|
||||
if (book.hasAudio) {
|
||||
createCacheRecord(
|
||||
bookId,
|
||||
"audio",
|
||||
"$absUrl/api/items/${book.remoteId}/download",
|
||||
)
|
||||
}
|
||||
|
||||
val inputData =
|
||||
Data
|
||||
.Builder()
|
||||
.putInt(CacheDownloadWorker.KEY_BOOK_ID, bookId)
|
||||
.putString(CacheDownloadWorker.KEY_REMOTE_ID, book.remoteId)
|
||||
.putString(CacheDownloadWorker.KEY_TITLE, book.title)
|
||||
.putString(CacheDownloadWorker.KEY_MEDIA_TYPE, book.mediaType)
|
||||
.putBoolean(CacheDownloadWorker.KEY_HAS_AUDIO, book.hasAudio)
|
||||
.putBoolean(CacheDownloadWorker.KEY_HAS_EBOOK, book.hasEbook)
|
||||
.putString(CacheDownloadWorker.KEY_COVER_URL, book.coverUrl)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
|
||||
.setInputData(inputData)
|
||||
.setConstraints(
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
CacheDownloadWorker.workName(bookId),
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId) ?: return@withContext
|
||||
val records = database.cachedFileDao.getByBookId(book.id)
|
||||
records.forEach { record ->
|
||||
record.localPath?.let { path ->
|
||||
File(path).delete()
|
||||
}
|
||||
database.cachedFileDao.delete(record)
|
||||
}
|
||||
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> =
|
||||
flow {
|
||||
val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id
|
||||
if (bookId == null) {
|
||||
emit(
|
||||
CacheStatus(
|
||||
remoteId = remoteId,
|
||||
state = CacheState.NONE,
|
||||
progress = 0f,
|
||||
cachedFiles = emptyList(),
|
||||
),
|
||||
)
|
||||
return@flow
|
||||
}
|
||||
|
||||
database.cachedFileDao.observeByBookId(bookId).map { records ->
|
||||
records.toCacheStatus(remoteId)
|
||||
}.collect { emit(it) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun findOrInsertBook(book: Book): BookEntity {
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
return if (existing != null) {
|
||||
val updated =
|
||||
existing.copy(
|
||||
title = book.title,
|
||||
author = book.author.getAsString() ?: "",
|
||||
coverUrl = book.coverUrl,
|
||||
hasAudio = book.hasAudio,
|
||||
hasEbook = book.hasEbook,
|
||||
audioDuration = book.audioDuration,
|
||||
mediaType = book.mediaType,
|
||||
libraryId = book.libraryId,
|
||||
lastSyncedAt = System.currentTimeMillis(),
|
||||
)
|
||||
database.bookDao.updateBook(updated)
|
||||
database.bookDao.findBookByRemoteId(book.remoteId) ?: updated
|
||||
} else {
|
||||
val entity = bookMapper.toBookEntity(book.copy(filePath = ""))
|
||||
database.bookDao.insertBook(entity)
|
||||
database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
?: error("Failed to insert book for caching")
|
||||
}
|
||||
}
|
||||
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||
|
||||
private suspend fun createCacheRecord(
|
||||
bookId: Int,
|
||||
type: String,
|
||||
url: String,
|
||||
if (existing != null &&
|
||||
cachedEbook?.localPath != null &&
|
||||
File(cachedEbook.localPath).exists()
|
||||
) {
|
||||
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
|
||||
if (existing == null) {
|
||||
database.cachedFileDao.insert(
|
||||
CachedFileEntity(
|
||||
bookId = bookId,
|
||||
type = type,
|
||||
remoteUrl = url,
|
||||
status = "pending",
|
||||
progress = 0f,
|
||||
),
|
||||
if (existing.filePath != cachedEbook.localPath) {
|
||||
database.bookDao.updateBook(
|
||||
bookMapper.toBookEntity(
|
||||
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
||||
)
|
||||
)
|
||||
}
|
||||
return@withContext existing.id
|
||||
}
|
||||
|
||||
val url = serverSettings.getAbsUrl()
|
||||
val token = serverSettings.getAbsToken()
|
||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||
throw IllegalStateException("Audiobookshelf server is not configured.")
|
||||
}
|
||||
|
||||
val client = apiFactory.provideClient(url, token)
|
||||
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
||||
|
||||
val response = client.downloadBook(book.remoteId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Download failed: ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
?: throw IllegalStateException("Download response body is empty.")
|
||||
|
||||
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
|
||||
val fileName = guessFileName(response, book.remoteId)
|
||||
val file = File(ebooksDir, fileName)
|
||||
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val localBook = if (existing != null) {
|
||||
book.copy(
|
||||
id = existing.id,
|
||||
filePath = file.absolutePath,
|
||||
scrollIndex = existing.scrollIndex,
|
||||
scrollOffset = existing.scrollOffset,
|
||||
progress = existing.progress,
|
||||
categories = existing.categories,
|
||||
)
|
||||
} else if (existing.status == "completed" &&
|
||||
existing.localPath != null &&
|
||||
File(existing.localPath).exists()
|
||||
) {
|
||||
// Already cached locally, leave it alone.
|
||||
} else {
|
||||
database.cachedFileDao.update(
|
||||
existing.copy(status = "pending", progress = 0f),
|
||||
book.copy(filePath = file.absolutePath)
|
||||
}
|
||||
|
||||
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
|
||||
|
||||
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
?: throw IllegalStateException("Could not insert or update book.")
|
||||
|
||||
database.ebookFileDao.insert(
|
||||
EbookFileEntity(
|
||||
bookId = inserted.id,
|
||||
fileId = book.remoteId,
|
||||
format = file.extension,
|
||||
localPath = file.absolutePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CachedFileEntity>.toCacheStatus(remoteId: String): CacheStatus {
|
||||
if (isEmpty()) {
|
||||
return CacheStatus(
|
||||
remoteId = remoteId,
|
||||
state = CacheState.NONE,
|
||||
progress = 0f,
|
||||
cachedFiles = emptyList(),
|
||||
)
|
||||
|
||||
inserted.id
|
||||
}
|
||||
}
|
||||
|
||||
val files =
|
||||
map {
|
||||
CachedFile(
|
||||
type = it.type,
|
||||
status = it.status,
|
||||
progress = it.progress,
|
||||
localPath = it.localPath,
|
||||
)
|
||||
}
|
||||
|
||||
val state =
|
||||
when {
|
||||
files.any { it.status == "failed" } -> CacheState.FAILED
|
||||
files.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||
files.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||
else -> CacheState.PENDING
|
||||
}
|
||||
|
||||
val progress =
|
||||
when (state) {
|
||||
CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat()
|
||||
CacheState.COMPLETED -> 1f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
return CacheStatus(
|
||||
remoteId = remoteId,
|
||||
state = state,
|
||||
progress = progress,
|
||||
cachedFiles = files,
|
||||
)
|
||||
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
|
||||
val contentDisposition = response.headers()["Content-Disposition"]
|
||||
val mimeType = response.body()?.contentType()?.toString()
|
||||
val url = response.raw().request.url.toString()
|
||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||
return if (name.contains(".")) name else "$remoteId.epub"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@
|
|||
package org.dueattendant149.bookshelf.data.service
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import org.dueattendant149.bookshelf.data.model.file.CachedFile
|
||||
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.service.FileProvider
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class FileProviderImpl @Inject constructor(
|
||||
|
|
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
|
|||
) : FileProvider {
|
||||
|
||||
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching {
|
||||
if (book.filePath.isBlank()) {
|
||||
throw NoSuchElementException("Book file path is empty.")
|
||||
}
|
||||
|
||||
val plainFile = File(book.filePath)
|
||||
if (plainFile.exists() && plainFile.isFile) {
|
||||
return@runCatching CachedFileCompat.fromUri(
|
||||
application,
|
||||
Uri.fromFile(plainFile),
|
||||
builder = CachedFileCompat.build(
|
||||
name = plainFile.name,
|
||||
path = plainFile.absolutePath,
|
||||
size = plainFile.length(),
|
||||
lastModified = plainFile.lastModified(),
|
||||
isDirectory = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
application.contentResolver.persistedUriPermissions.forEach { storage ->
|
||||
val storageFile = CachedFileCompat.fromUri(
|
||||
application,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@
|
|||
|
||||
package org.dueattendant149.bookshelf.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
|
||||
interface CacheRepository {
|
||||
suspend fun cacheBook(book: Book): Result<Unit>
|
||||
suspend fun deleteCache(remoteId: String): Result<Unit>
|
||||
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
|
||||
/**
|
||||
* Ensures the ebook file for [book] is available locally.
|
||||
*
|
||||
* @return the local database id of the book (existing or newly inserted).
|
||||
*/
|
||||
suspend fun cacheBook(book: Book): Result<Int>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,8 @@ class CacheBookUseCase
|
|||
constructor(
|
||||
private val repository: CacheRepository,
|
||||
) {
|
||||
suspend operator fun invoke(book: Book): Result<Unit> = repository.cacheBook(book)
|
||||
/**
|
||||
* Ensures the ebook file for [book] is cached locally and returns the local book id.
|
||||
*/
|
||||
suspend operator fun invoke(book: Book): Result<Int> = repository.cacheBook(book)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class RemoteLibraryEffect {
|
||||
data class OnNavigateToReader(val bookId: Int) : RemoteLibraryEffect()
|
||||
}
|
||||
|
|
@ -9,20 +9,18 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
import javax.inject.Inject
|
||||
|
|
@ -33,8 +31,7 @@ data class RemoteLibraryState(
|
|||
val selectedLibrary: RemoteLibrary? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
||||
val offlineOnly: Boolean = false,
|
||||
val cachingBookId: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -44,13 +41,13 @@ class RemoteLibraryModel
|
|||
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||
private val fetchBooksUseCase: FetchBooksUseCase,
|
||||
private val cacheBookUseCase: CacheBookUseCase,
|
||||
private val deleteCacheUseCase: DeleteCacheUseCase,
|
||||
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||
private val serverSettings: ServerSettings,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(RemoteLibraryState())
|
||||
val state = _state.asStateFlow()
|
||||
private var cacheStatusJob: Job? = null
|
||||
|
||||
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadLibraries()
|
||||
|
|
@ -90,55 +87,25 @@ class RemoteLibraryModel
|
|||
error = result.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
observeCacheStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
fun cacheBook(book: Book) {
|
||||
fun openBook(book: Book) {
|
||||
if (!book.hasEbook || book.remoteId.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = cacheBookUseCase(book)
|
||||
if (result.isFailure) {
|
||||
_state.update {
|
||||
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
||||
}
|
||||
}
|
||||
observeCacheStatuses()
|
||||
}
|
||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
cacheBookUseCase(book)
|
||||
}
|
||||
_state.update { it.copy(cachingBookId = null) }
|
||||
|
||||
fun deleteCache(book: Book) {
|
||||
viewModelScope.launch {
|
||||
val result = deleteCacheUseCase(book.remoteId)
|
||||
if (result.isFailure) {
|
||||
_state.update {
|
||||
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleOfflineOnly() {
|
||||
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
||||
}
|
||||
|
||||
private fun observeCacheStatuses() {
|
||||
cacheStatusJob?.cancel()
|
||||
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
||||
if (remoteIds.isEmpty()) {
|
||||
_state.update { it.copy(cacheStatuses = emptyMap()) }
|
||||
return
|
||||
}
|
||||
|
||||
cacheStatusJob =
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
remoteIds.map { remoteId ->
|
||||
getCacheStatusUseCase(remoteId)
|
||||
},
|
||||
) { statuses ->
|
||||
statuses.toList().associateBy { it.remoteId }
|
||||
}.collect { statuses ->
|
||||
_state.update { it.copy(cacheStatuses = statuses) }
|
||||
result
|
||||
.onSuccess { bookId ->
|
||||
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,15 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
|
||||
@Parcelize
|
||||
object RemoteLibraryScreen : Screen, Parcelable {
|
||||
|
|
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
|||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<RemoteLibraryModel>()
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
LaunchedEffect(model.effects, navigator) {
|
||||
model.effects.collectLatest { effect ->
|
||||
when (effect) {
|
||||
is RemoteLibraryEffect.OnNavigateToReader -> {
|
||||
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
|
||||
navigator.push(ReaderScreen(effect.bookId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RemoteLibraryContent(model = model)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,77 +7,71 @@
|
|||
package org.dueattendant149.bookshelf.ui.bookshelf
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudQueue
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
|
||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val refreshState = rememberPullRefreshState(
|
||||
refreshing = state.isLoading,
|
||||
onRefresh = model::loadLibraries
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.bookshelf_screen)) }
|
||||
title = { Text("Bookshelf") }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(refreshState)
|
||||
.padding(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Button(
|
||||
onClick = model::loadLibraries,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Refresh libraries")
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
item {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { error ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_libraries_header),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(state.libraries.size) { index ->
|
||||
val library = state.libraries[index]
|
||||
|
|
@ -86,43 +80,15 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
onClick = { model.selectLibrary(library) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = library.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.bookshelf_library_subtitle,
|
||||
library.mediaType,
|
||||
library.itemCount
|
||||
)
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (selected) {
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_selected_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedLibrary?.let { library ->
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_books_header, library.name),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
text = "${library.mediaType} • ${library.itemCount} items",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -132,64 +98,30 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = book.author.getAsString()
|
||||
?: stringResource(R.string.unknown_author)
|
||||
text = book.author.getAsString() ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
if (book.hasEbook) {
|
||||
Button(
|
||||
onClick = { model.openBook(book) },
|
||||
enabled = state.cachingBookId != book.remoteId,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(stringResource(R.string.read))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
|
||||
EmptyPlaceholder(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
message = stringResource(R.string.bookshelf_empty_message),
|
||||
icon = rememberVectorPainter(Icons.Default.CloudQueue),
|
||||
actionTitle = stringResource(R.string.bookshelf_refresh_action),
|
||||
action = model::loadLibraries
|
||||
)
|
||||
}
|
||||
|
||||
state.error?.let { error ->
|
||||
ErrorPlaceholder(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
errorMessage = error,
|
||||
icon = rememberVectorPainter(Icons.Default.CloudOff),
|
||||
actionTitle = stringResource(R.string.bookshelf_retry_action),
|
||||
action = model::loadLibraries
|
||||
)
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = state.isLoading,
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
<string name="web_search">Web search</string>
|
||||
<string name="share">Share</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="read">Read</string>
|
||||
<string name="change_path">Change path</string>
|
||||
<string name="add_folder">Add folder</string>
|
||||
<string name="edit">Edit</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue