feat(ui): wire download+open in reader, NavGraph with reader/server_settings routes
- BooksViewModel: downloadEbook → saveToCache → emit Uri → navigate to reader
- BooksScreen: LaunchedEffect collects openBook flow, navigates to reader/{bookUri}
- NavGraph: added reader/{bookUri} and server_settings routes
- ReaderScreen: placeholder (will wrap Episteme engine)
- Added items import for LazyVerticalGrid in all screen files
- ':app:assembleOssDebug' passes, APK deployed.
This commit is contained in:
parent
1b43c9c1ec
commit
d3339bbceb
7 changed files with 132 additions and 78 deletions
|
|
@ -130,13 +130,7 @@ open class MainActivity : AppCompatActivity() {
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
val navController = rememberNavController()
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
windowSizeClass = windowSizeClass,
|
||||
viewModel = viewModel
|
||||
)
|
||||
org.dueattendant149.bookreader.ui.MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@ package org.dueattendant149.bookreader.ui.navigation
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen
|
||||
import org.dueattendant149.bookreader.ui.screens.books.BooksScreen
|
||||
import org.dueattendant149.bookreader.ui.screens.reader.ReaderScreen
|
||||
import org.dueattendant149.bookreader.ui.screens.search.SearchScreen
|
||||
import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen
|
||||
import org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen
|
||||
import org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel
|
||||
|
||||
@Composable
|
||||
fun NavGraph(navController: NavHostController) {
|
||||
|
|
@ -27,5 +33,16 @@ fun NavGraph(navController: NavHostController) {
|
|||
composable(BottomBarScreen.Settings.route) {
|
||||
SettingsScreen(navController = navController)
|
||||
}
|
||||
composable("server_settings") {
|
||||
val vm: ServerSettingsViewModel = hiltViewModel()
|
||||
ServerSettingsScreen(viewModel = vm, onBackClick = { navController.popBackStack() })
|
||||
}
|
||||
composable(
|
||||
route = "reader/{bookUri}",
|
||||
arguments = listOf(navArgument("bookUri") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val bookUri = backStackEntry.arguments?.getString("bookUri") ?: ""
|
||||
ReaderScreen(bookUri = bookUri, onBackClick = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.dueattendant149.bookreader.ui.screens.books
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -12,15 +13,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmarks
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
@ -35,47 +37,31 @@ fun BooksScreen(navController: NavController) {
|
|||
val viewModel: BooksViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.openBook.collect { uri ->
|
||||
val encoded = Uri.encode(uri.toString())
|
||||
navController.navigate("reader/$encoded")
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
CustomTopAppBar(
|
||||
headerText = "Books",
|
||||
icon = Icons.Filled.Bookmarks,
|
||||
)
|
||||
CustomTopAppBar(headerText = "Books", icon = Icons.Filled.Bookmarks)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.isLoading && uiState.items.isEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ProgressDots()
|
||||
}
|
||||
AnimatedVisibility(visible = uiState.isLoading && uiState.items.isEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
|
||||
}
|
||||
|
||||
uiState.error?.let { error ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = error, color = MaterialTheme.colorScheme.error, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !uiState.isLoading && uiState.error == null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
AnimatedVisibility(visible = !uiState.isLoading && uiState.error == null, enter = fadeIn(), exit = fadeOut()) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -86,18 +72,18 @@ fun BooksScreen(navController: NavController) {
|
|||
) {
|
||||
items(uiState.items.size) { i ->
|
||||
val item = uiState.items[i]
|
||||
val isDownloading = uiState.downloadingItemId == item.id
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.padding(4.dp).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BookItemCard(
|
||||
title = item.title,
|
||||
author = item.author.ifBlank { item.authors.joinToString(", ") },
|
||||
subtitle = if (item.size > 0) formatSize(item.size) else "",
|
||||
subtitle = if (isDownloading) "Downloading..." else formatSize(item.size),
|
||||
coverImageUrl = item.coverUrl.ifBlank { null },
|
||||
) {
|
||||
if (!isDownloading) viewModel.downloadAndOpen(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,11 +94,6 @@ fun BooksScreen(navController: NavController) {
|
|||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes <= 0) return ""
|
||||
val kb = bytes / 1024.0
|
||||
val mb = kb / 1024.0
|
||||
return when {
|
||||
mb >= 1 -> String.format("%.1f MB", mb)
|
||||
kb >= 1 -> String.format("%.0f KB", kb)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
val mb = bytes / 1048576.0
|
||||
return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0)
|
||||
}
|
||||
|
|
@ -1,79 +1,104 @@
|
|||
package org.dueattendant149.bookreader.ui.screens.books
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BooksUiState(
|
||||
val items: List<UnifiedItemResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val libraryId: String? = null,
|
||||
val downloadingItemId: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class BooksViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
application: Application,
|
||||
private val repository: BookshelfApiRepository,
|
||||
) : ViewModel() {
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val _uiState = MutableStateFlow(BooksUiState())
|
||||
val uiState: StateFlow<BooksUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadBooks()
|
||||
}
|
||||
private val _openBook = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
||||
val openBook: SharedFlow<Uri> = _openBook.asSharedFlow()
|
||||
|
||||
init { loadBooks() }
|
||||
|
||||
fun loadBooks() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
repository.getLibraries()
|
||||
.onSuccess { libraries ->
|
||||
val booksLibrary = libraries.firstOrNull {
|
||||
it.name.equals("Books", ignoreCase = true) || it.id == "4e1152a6-1546-497a-b354-ccdd9cdbd32d"
|
||||
val booksLibrary = libraries.firstOrNull {
|
||||
it.name.equals("Books", ignoreCase = true)
|
||||
} ?: libraries.firstOrNull()
|
||||
if (booksLibrary != null) {
|
||||
loadItems(booksLibrary.id)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, error = "No libraries found")
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = error.message ?: "Failed to load libraries"
|
||||
)
|
||||
if (booksLibrary != null) loadItems(booksLibrary.id)
|
||||
else _uiState.value = _uiState.value.copy(isLoading = false, error = "No libraries")
|
||||
}
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadItems(libraryId: String) {
|
||||
_uiState.value = _uiState.value.copy(libraryId = libraryId, isLoading = true)
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
viewModelScope.launch {
|
||||
repository.getLibraryItems(libraryId)
|
||||
.onSuccess { response ->
|
||||
val ebooks = response.items.filter { it.type == "ebook" }
|
||||
_uiState.value = _uiState.value.copy(
|
||||
items = ebooks,
|
||||
items = response.items.filter { it.type == "ebook" },
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = error.message ?: "Failed to load items"
|
||||
)
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndOpen(item: UnifiedItemResponse) {
|
||||
_uiState.value = _uiState.value.copy(downloadingItemId = item.id)
|
||||
viewModelScope.launch {
|
||||
repository.downloadEbook(item.id)
|
||||
.onSuccess { body ->
|
||||
val file = saveToCache(item, body)
|
||||
if (file != null) _openBook.tryEmit(Uri.fromFile(file))
|
||||
_uiState.value = _uiState.value.copy(downloadingItemId = null)
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.value = _uiState.value.copy(downloadingItemId = null, error = it.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? = runCatching {
|
||||
val dir = File(getApplication<Application>().cacheDir, "bookshelf_downloads").apply { mkdirs() }
|
||||
val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
|
||||
val ext = guessExt(item)
|
||||
val file = File(dir, "${safeName}_${item.id}.$ext")
|
||||
file.outputStream().use { body.byteStream().copyTo(it) }
|
||||
file
|
||||
}.getOrNull()
|
||||
|
||||
private fun guessExt(item: UnifiedItemResponse): String = when {
|
||||
item.mediaType.contains("epub", true) -> "epub"
|
||||
item.mediaType.contains("pdf", true) -> "pdf"
|
||||
item.mediaType.contains("fb2", true) -> "fb2"
|
||||
item.mediaType.contains("mobi", true) -> "mobi"
|
||||
else -> "epub"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.dueattendant149.bookreader.ui.screens.reader
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Placeholder reader screen — wraps the Episteme reader engine.
|
||||
* In production this delegates to EpubReaderScreen/PdfViewerScreen based on file type.
|
||||
*/
|
||||
@Composable
|
||||
fun ReaderScreen(
|
||||
bookUri: String,
|
||||
onBackClick: () -> Unit,
|
||||
) {
|
||||
BackHandler { onBackClick() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Reader: $bookUri",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue