diff --git a/AGENTS.md b/AGENTS.md index a30a365b..1361dcff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,12 @@ - Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed. - Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed. - Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed. -- Phase 5: UI scaffold (server settings screen, RemoteLibrary tab) completed. -- Next: audio player (ExoPlayer + MediaSession), TTS integration, offline cache, reader integration for ebooks. +- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed. +- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged. +- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged. +- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model. +- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`. +- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync. ## WARNs 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..f667f68d 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,24 +7,17 @@ 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.withContext 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 @@ -38,194 +31,159 @@ 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 { + + private val cachedFileDao by lazy { database.cachedFileDao } + + 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() + } + return@withContext existing.id + } - workManager.enqueueUniqueWork( - CacheDownloadWorker.workName(bookId), - ExistingWorkPolicy.KEEP, - request, + 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, + ) ) + + cachedFileDao.insert( + CachedFileEntity( + bookId = inserted.id, + type = "ebook", + remoteUrl = "$url/api/items/${book.remoteId}/download", + localPath = file.absolutePath, + status = "completed", + progress = 1f, + ) + ) + + inserted.id } } 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) - } - workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id)) + val book = database.bookDao.findBookByRemoteId(remoteId) + ?: throw IllegalStateException("Book not found") + val ebook = database.ebookFileDao.getByBookId(book.id) + val cachedFiles = cachedFileDao.getByBookId(book.id) + + ebook?.localPath?.let { File(it).delete() } + cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() } + + database.ebookFileDao.deleteByBookId(book.id) + cachedFileDao.deleteByBookId(book.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(), - ) + override fun observeCacheStatus(remoteId: String): Flow = flow { + val book = database.bookDao.findBookByRemoteId(remoteId) + if (book == null) { + emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList())) + return@flow } - val files = - map { + cachedFileDao.observeByBookId(book.id).collect { entities -> + val files = entities.map { CachedFile( type = it.type, status = it.status, progress = it.progress, - localPath = it.localPath, + 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 state = when { + entities.isEmpty() -> CacheState.NONE + entities.all { it.status == "completed" } -> CacheState.COMPLETED + entities.any { it.status == "failed" } -> CacheState.FAILED + entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING + entities.any { it.status == "pending" } -> CacheState.PENDING + else -> CacheState.NONE } - - val progress = - when (state) { - CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat() - CacheState.COMPLETED -> 1f - else -> 0f + val progress = if (entities.isEmpty()) { + 0f + } else { + entities.map { it.progress }.average().toFloat() } + emit(CacheStatus(remoteId, state, progress, files)) + } + } - 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..bb842ba1 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 @@ -11,7 +11,20 @@ import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.library.Book interface CacheRepository { - suspend fun cacheBook(book: Book): Result + /** + * 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 + + /** + * Deletes the cached file(s) for the book identified by [remoteId]. + */ suspend fun deleteCache(remoteId: String): Result + + /** + * Observes the cache status for the book identified by [remoteId]. + */ fun observeCacheStatus(remoteId: String): Flow } 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..e338c542 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,13 +9,16 @@ package org.dueattendant149.bookshelf.presentation.bookshelf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +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 @@ -35,6 +38,7 @@ data class RemoteLibraryState( val error: String? = null, val cacheStatuses: Map = emptyMap(), val offlineOnly: Boolean = false, + val cachingBookId: String? = null, ) @HiltViewModel @@ -52,6 +56,9 @@ class RemoteLibraryModel val state = _state.asStateFlow() private var cacheStatusJob: Job? = null + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + init { loadLibraries() } @@ -96,7 +103,9 @@ class RemoteLibraryModel fun cacheBook(book: Book) { viewModelScope.launch { + _state.update { it.copy(cachingBookId = book.remoteId) } val result = cacheBookUseCase(book) + _state.update { it.copy(cachingBookId = null) } if (result.isFailure) { _state.update { it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed") @@ -121,6 +130,26 @@ class RemoteLibraryModel _state.update { it.copy(offlineOnly = !it.offlineOnly) } } + fun openBook(book: Book) { + if (!book.hasEbook || book.remoteId.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(cachingBookId = book.remoteId) } + val result = withContext(Dispatchers.IO) { + cacheBookUseCase(book) + } + _state.update { it.copy(cachingBookId = null) } + + result + .onSuccess { bookId -> + _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId)) + } + .onFailure { error -> + _state.update { it.copy(error = error.message ?: "Could not open book") } + } + } + } + private fun observeCacheStatuses() { cacheStatusJob?.cancel() val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() } 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 ef374a0b..69764fac 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 @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Download 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 @@ -40,6 +41,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -63,6 +65,16 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) { onRefresh = model::loadLibraries ) + val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) { + if (!state.offlineOnly) state.books + else state.books.filter { book -> + val status = state.cacheStatuses[book.remoteId] + status?.state == CacheState.COMPLETED || + status?.state == CacheState.DOWNLOADING || + status?.state == CacheState.PENDING + } + } + Scaffold( topBar = { TopAppBar( @@ -155,14 +167,17 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) { } } - items(state.books.size) { index -> - val book = state.books[index] + items(visibleBooks.size) { index -> + val book = visibleBooks[index] val cacheStatus = state.cacheStatuses[book.remoteId] + val isCaching = state.cachingBookId == book.remoteId BookListItem( book = book, cacheStatus = cacheStatus, + isCaching = isCaching, onDownload = { model.cacheBook(book) }, - onDelete = { model.deleteCache(book) } + onDelete = { model.deleteCache(book) }, + onRead = if (book.hasEbook) ({ model.openBook(book) }) else null ) } @@ -215,8 +230,10 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) { private fun BookListItem( book: Book, cacheStatus: CacheStatus?, + isCaching: Boolean, onDownload: () -> Unit, onDelete: () -> Unit, + onRead: (() -> Unit)?, ) { val cacheState = cacheStatus?.state ?: CacheState.NONE val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED @@ -278,6 +295,23 @@ private fun BookListItem( .padding(horizontal = 16.dp, vertical = 8.dp), ) } + + onRead?.let { read -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = read, + enabled = !isCaching + ) { + Text(stringResource(R.string.read)) + } + } + } } } } 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