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:
Atte149 2026-06-16 11:48:30 +03:00
parent ed3afc1f16
commit d1257e1d60
11 changed files with 247 additions and 387 deletions

View file

@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.BookRepository 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.CategoryRepository
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
@ -96,6 +97,12 @@ abstract class RepositoryModule {
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
): RemoteLibraryRepository ): RemoteLibraryRepository
@Binds
@Singleton
abstract fun bindCacheRepository(
cacheRepositoryImpl: CacheRepositoryImpl
): CacheRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindBookMapper( abstract fun bindBookMapper(

View file

@ -50,6 +50,11 @@ class CachedFile(
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
fun canAccess(): Boolean { fun canAccess(): Boolean {
if (uri.scheme == "file") {
val file = File(uri.path ?: return false)
return file.exists() && file.canRead()
}
return try { return try {
context.contentResolver.query(uri, null, null, null, null)?.let { context.contentResolver.query(uri, null, null, null, null)?.let {
it.close() it.close()

View file

@ -7,27 +7,14 @@
package org.dueattendant149.bookshelf.data.repository package org.dueattendant149.bookshelf.data.repository
import android.app.Application import android.app.Application
import androidx.work.Constraints import android.webkit.URLUtil
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.local.dto.BookEntity import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper 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.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.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.CacheRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import java.io.File import java.io.File
@ -38,194 +25,98 @@ import javax.inject.Singleton
class CacheRepositoryImpl class CacheRepositoryImpl
@Inject @Inject
constructor( constructor(
private val application: Application,
private val database: BookDatabase, private val database: BookDatabase,
private val bookMapper: BookMapper, private val bookMapper: BookMapper,
private val apiFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
private val workManager: WorkManager,
private val application: Application,
) : CacheRepository { ) : CacheRepository {
override suspend fun cacheBook(book: Book): Result<Unit> = runCatching {
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val localBook = findOrInsertBook(book) require(book.hasEbook) { "Book has no ebook." }
val bookId = localBook.id require(book.remoteId.isNotBlank()) { "Book has no remote id." }
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
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) val existing = database.bookDao.findBookByRemoteId(book.remoteId)
return if (existing != null) { val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
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( if (existing != null &&
bookId: Int, cachedEbook?.localPath != null &&
type: String, File(cachedEbook.localPath).exists()
url: String,
) { ) {
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type) if (existing.filePath != cachedEbook.localPath) {
if (existing == null) { database.bookDao.updateBook(
database.cachedFileDao.insert( bookMapper.toBookEntity(
CachedFileEntity( bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
bookId = bookId, )
type = type, )
remoteUrl = url, }
status = "pending", return@withContext existing.id
progress = 0f, }
),
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 { } else {
database.cachedFileDao.update( book.copy(filePath = file.absolutePath)
existing.copy(status = "pending", progress = 0f), }
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 = private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
map { val contentDisposition = response.headers()["Content-Disposition"]
CachedFile( val mimeType = response.body()?.contentType()?.toString()
type = it.type, val url = response.raw().request.url.toString()
status = it.status, val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
progress = it.progress, return if (name.contains(".")) name else "$remoteId.epub"
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,
)
} }
} }

View file

@ -7,10 +7,12 @@
package org.dueattendant149.bookshelf.data.service package org.dueattendant149.bookshelf.data.service
import android.app.Application import android.app.Application
import android.net.Uri
import org.dueattendant149.bookshelf.data.model.file.CachedFile import org.dueattendant149.bookshelf.data.model.file.CachedFile
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.service.FileProvider import org.dueattendant149.bookshelf.domain.service.FileProvider
import java.io.File
import javax.inject.Inject import javax.inject.Inject
class FileProviderImpl @Inject constructor( class FileProviderImpl @Inject constructor(
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
) : FileProvider { ) : FileProvider {
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching { 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 -> application.contentResolver.persistedUriPermissions.forEach { storage ->
val storageFile = CachedFileCompat.fromUri( val storageFile = CachedFileCompat.fromUri(
application, application,

View file

@ -6,12 +6,13 @@
package org.dueattendant149.bookshelf.domain.repository 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 import org.dueattendant149.bookshelf.domain.model.library.Book
interface CacheRepository { interface CacheRepository {
suspend fun cacheBook(book: Book): Result<Unit> /**
suspend fun deleteCache(remoteId: String): Result<Unit> * Ensures the ebook file for [book] is available locally.
fun observeCacheStatus(remoteId: String): Flow<CacheStatus> *
* @return the local database id of the book (existing or newly inserted).
*/
suspend fun cacheBook(book: Book): Result<Int>
} }

View file

@ -15,5 +15,8 @@ class CacheBookUseCase
constructor( constructor(
private val repository: CacheRepository, 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)
} }

View file

@ -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()
}

View file

@ -9,20 +9,18 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.settings.ServerSettings 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.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary 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.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.FetchBooksUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject import javax.inject.Inject
@ -33,8 +31,7 @@ data class RemoteLibraryState(
val selectedLibrary: RemoteLibrary? = null, val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(), val cachingBookId: String? = null,
val offlineOnly: Boolean = false,
) )
@HiltViewModel @HiltViewModel
@ -44,13 +41,13 @@ class RemoteLibraryModel
private val fetchLibrariesUseCase: FetchLibrariesUseCase, private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val fetchBooksUseCase: FetchBooksUseCase, private val fetchBooksUseCase: FetchBooksUseCase,
private val cacheBookUseCase: CacheBookUseCase, private val cacheBookUseCase: CacheBookUseCase,
private val deleteCacheUseCase: DeleteCacheUseCase,
private val getCacheStatusUseCase: GetCacheStatusUseCase,
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(RemoteLibraryState()) private val _state = MutableStateFlow(RemoteLibraryState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
val effects = _effects.asSharedFlow()
init { init {
loadLibraries() loadLibraries()
@ -90,55 +87,25 @@ class RemoteLibraryModel
error = result.exceptionOrNull()?.message, error = result.exceptionOrNull()?.message,
) )
} }
observeCacheStatuses()
} }
} }
fun cacheBook(book: Book) { fun openBook(book: Book) {
if (!book.hasEbook || book.remoteId.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
val result = cacheBookUseCase(book) _state.update { it.copy(cachingBookId = book.remoteId) }
if (result.isFailure) { val result = withContext(Dispatchers.IO) {
_state.update { cacheBookUseCase(book)
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
}
}
observeCacheStatuses()
}
} }
_state.update { it.copy(cachingBookId = null) }
fun deleteCache(book: Book) { result
viewModelScope.launch { .onSuccess { bookId ->
val result = deleteCacheUseCase(book.remoteId) _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
if (result.isFailure) { }
_state.update { .onFailure { error ->
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed") _state.update { it.copy(error = error.message ?: "Could not open book") }
}
}
}
}
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) }
} }
} }
} }

View file

@ -8,10 +8,15 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen 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.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize @Parcelize
object RemoteLibraryScreen : Screen, Parcelable { object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable @Composable
override fun Content() { override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>() 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) RemoteLibraryContent(model = model)
} }
} }

View file

@ -7,77 +7,71 @@
package org.dueattendant149.bookshelf.ui.bookshelf package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.Button
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.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel 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 @Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) { fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.bookshelf_screen)) } title = { Text("Bookshelf") }
) )
} }
) { padding -> ) { padding ->
Box( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.pullRefresh(refreshState) .padding(padding),
.padding(padding)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.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 { item {
Text( Text(
text = stringResource(R.string.bookshelf_libraries_header), text = error,
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
) )
} }
}
items(state.libraries.size) { index -> items(state.libraries.size) { index ->
val library = state.libraries[index] val library = state.libraries[index]
@ -86,43 +80,15 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
onClick = { model.selectLibrary(library) }, onClick = { model.selectLibrary(library) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
ListItem(
headlineContent = {
Text( Text(
text = library.name, text = library.name,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
) )
},
supportingContent = {
Text( Text(
text = stringResource( text = "${library.mediaType}${library.itemCount} items",
R.string.bookshelf_library_subtitle, style = MaterialTheme.typography.bodyMedium,
library.mediaType, modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
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)
) )
} }
} }
@ -132,64 +98,30 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
ListItem(
headlineContent = {
Text( Text(
text = book.title, text = book.title,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
) )
},
supportingContent = {
Text( Text(
text = book.author.getAsString() text = book.author.getAsString() ?: "",
?: stringResource(R.string.unknown_author) style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp)
) )
} if (book.hasEbook) {
) Button(
} onClick = { model.openBook(book) },
} enabled = state.cachingBookId != book.remoteId,
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
item {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 32.dp), .padding(horizontal = 16.dp)
contentAlignment = Alignment.Center .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
)
} }
} }
} }

View file

@ -98,6 +98,7 @@
<string name="web_search">Web search</string> <string name="web_search">Web search</string>
<string name="share">Share</string> <string name="share">Share</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="read">Read</string>
<string name="change_path">Change path</string> <string name="change_path">Change path</string>
<string name="add_folder">Add folder</string> <string name="add_folder">Add folder</string>
<string name="edit">Edit</string> <string name="edit">Edit</string>