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:
Atte149 2026-06-27 14:34:45 +03:00
parent 1b43c9c1ec
commit d3339bbceb
7 changed files with 132 additions and 78 deletions

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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"
}
}

View file

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

View file

@ -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