From d1257e1d60179313a43a7347181e89e00341f707 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Tue, 16 Jun 2026 11:48:30 +0300 Subject: [PATCH] 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. --- .../bookshelf/data/di/RepositoryModule.kt | 7 + .../bookshelf/data/model/file/CachedFile.kt | 5 + .../data/repository/CacheRepositoryImpl.kt | 275 ++++++------------ .../data/service/FileProviderImpl.kt | 21 ++ .../domain/repository/CacheRepository.kt | 11 +- .../domain/use_case/cache/CacheBookUseCase.kt | 5 +- .../bookshelf/RemoteLibraryEffect.kt | 14 + .../bookshelf/RemoteLibraryModel.kt | 75 ++--- .../bookshelf/RemoteLibraryScreen.kt | 18 ++ .../ui/bookshelf/RemoteLibraryContent.kt | 202 +++++-------- app/src/main/res/values/strings.xml | 1 + 11 files changed, 247 insertions(+), 387 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryEffect.kt diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt index 45b69adb..f29b779f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt @@ -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( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/model/file/CachedFile.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/model/file/CachedFile.kt index f7308ba7..fc46f756 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/model/file/CachedFile.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/model/file/CachedFile.kt @@ -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() diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt index 96e20511..686baa63 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt @@ -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 = runCatching { + + override suspend fun cacheBook(book: Book): Result = 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 existing = database.bookDao.findBookByRemoteId(book.remoteId) + val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) } - 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() - .setInputData(inputData) - .setConstraints( - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(), + if (existing != null && + cachedEbook?.localPath != null && + File(cachedEbook.localPath).exists() + ) { + if (existing.filePath != cachedEbook.localPath) { + database.bookDao.updateBook( + bookMapper.toBookEntity( + bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath) + ) ) - .build() - - workManager.enqueueUniqueWork( - CacheDownloadWorker.workName(bookId), - ExistingWorkPolicy.KEEP, - request, - ) - } - } - - override suspend fun deleteCache(remoteId: String): Result = 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) + return@withContext existing.id } - workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.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 { + 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, + ) + ) + + inserted.id } } - override fun observeCacheStatus(remoteId: String): Flow = - 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") - } - } - - private suspend fun createCacheRecord( - bookId: Int, - type: String, - url: String, - ) { - val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type) - if (existing == null) { - database.cachedFileDao.insert( - CachedFileEntity( - bookId = bookId, - type = type, - remoteUrl = url, - status = "pending", - progress = 0f, - ), - ) - } 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), - ) - } - } - - private fun List.toCacheStatus(remoteId: String): CacheStatus { - if (isEmpty()) { - return CacheStatus( - remoteId = remoteId, - state = CacheState.NONE, - progress = 0f, - cachedFiles = emptyList(), - ) - } - - 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, 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" } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/service/FileProviderImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/service/FileProviderImpl.kt index 1db9bbd5..80336fc6 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/service/FileProviderImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/service/FileProviderImpl.kt @@ -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 = 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, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt index fe985f86..4f4b1586 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt @@ -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 - suspend fun deleteCache(remoteId: String): Result - fun observeCacheStatus(remoteId: String): Flow + /** + * 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 } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt index 111fe9e5..5524ecb5 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt @@ -15,5 +15,8 @@ class CacheBookUseCase constructor( private val repository: CacheRepository, ) { - suspend operator fun invoke(book: Book): Result = 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 = repository.cacheBook(book) } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryEffect.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryEffect.kt new file mode 100644 index 00000000..e5d2d9c2 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryEffect.kt @@ -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() +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt index 23d53e0f..7abeeb85 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt @@ -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 = 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() + val effects = _effects.asSharedFlow() init { loadLibraries() @@ -90,56 +87,26 @@ 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") - } + _state.update { it.copy(cachingBookId = book.remoteId) } + val result = withContext(Dispatchers.IO) { + cacheBookUseCase(book) } - observeCacheStatuses() - } - } + _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") + result + .onSuccess { bookId -> + _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId)) } - } - } - } - - 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) } + .onFailure { error -> + _state.update { it.copy(error = error.message ?: "Could not open book") } } - } + } } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt index d75e4386..c5aedec1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt @@ -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() + 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) } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt index 3b934004..fe0ac248 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt @@ -7,189 +7,121 @@ 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) + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - 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] - val selected = state.selectedLibrary?.id == library.id - Card( - onClick = { model.selectLibrary(library) }, - modifier = Modifier.fillMaxWidth(), - ) { - ListItem( - headlineContent = { - Text( - text = library.name, - style = MaterialTheme.typography.titleMedium - ) - }, - 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 - ) - } - } - ) - } + items(state.libraries.size) { index -> + val library = state.libraries[index] + val selected = state.selectedLibrary?.id == library.id + Card( + onClick = { model.selectLibrary(library) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = library.name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + Text( + text = "${library.mediaType} • ${library.itemCount} items", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp) + ) } + } - 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) - ) - } - } - - items(state.books.size) { index -> - val book = state.books[index] - Card( - modifier = Modifier.fillMaxWidth(), - ) { - ListItem( - headlineContent = { - Text( - text = book.title, - style = MaterialTheme.typography.titleMedium - ) - }, - supportingContent = { - Text( - text = book.author.getAsString() - ?: stringResource(R.string.unknown_author) - ) - } - ) - } - } - - if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) { - item { - Box( + items(state.books.size) { index -> + val book = state.books[index] + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = book.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + Text( + text = book.author.getAsString() ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + 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 - ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73a879e0..52c2b28e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,6 +98,7 @@ Web search Share OK + Read Change path Add folder Edit