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(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
val windowSizeClass = calculateWindowSizeClass(this) org.dueattendant149.bookreader.ui.MainScreen()
val navController = rememberNavController()
AppNavigation(
navController = navController,
windowSizeClass = windowSizeClass,
viewModel = viewModel
)
} }
} }
} }

View file

@ -2,12 +2,18 @@ package org.dueattendant149.bookreader.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.audiobooks.AudiobooksScreen
import org.dueattendant149.bookreader.ui.screens.books.BooksScreen 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.search.SearchScreen
import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen
import org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen
import org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel
@Composable @Composable
fun NavGraph(navController: NavHostController) { fun NavGraph(navController: NavHostController) {
@ -27,5 +33,16 @@ fun NavGraph(navController: NavHostController) {
composable(BottomBarScreen.Settings.route) { composable(BottomBarScreen.Settings.route) {
SettingsScreen(navController = navController) 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.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.Icons
import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme

View file

@ -1,5 +1,6 @@
package org.dueattendant149.bookreader.ui.screens.books package org.dueattendant149.bookreader.ui.screens.books
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.Icons
import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -35,47 +37,31 @@ fun BooksScreen(navController: NavController) {
val viewModel: BooksViewModel = hiltViewModel() val viewModel: BooksViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.openBook.collect { uri ->
val encoded = Uri.encode(uri.toString())
navController.navigate("reader/$encoded")
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
) { ) {
CustomTopAppBar( CustomTopAppBar(headerText = "Books", icon = Icons.Filled.Bookmarks)
headerText = "Books",
icon = Icons.Filled.Bookmarks,
)
AnimatedVisibility( AnimatedVisibility(visible = uiState.isLoading && uiState.items.isEmpty(), enter = fadeIn(), exit = fadeOut()) {
visible = uiState.isLoading && uiState.items.isEmpty(), Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProgressDots() }
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
ProgressDots()
}
} }
uiState.error?.let { error -> uiState.error?.let { error ->
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxSize(), Text(text = error, color = MaterialTheme.colorScheme.error, fontSize = 16.sp)
contentAlignment = Alignment.Center,
) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
fontSize = 16.sp,
)
} }
} }
AnimatedVisibility( AnimatedVisibility(visible = !uiState.isLoading && uiState.error == null, enter = fadeIn(), exit = fadeOut()) {
visible = !uiState.isLoading && uiState.error == null,
enter = fadeIn(),
exit = fadeOut(),
) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -86,18 +72,18 @@ fun BooksScreen(navController: NavController) {
) { ) {
items(uiState.items.size) { i -> items(uiState.items.size) { i ->
val item = uiState.items[i] val item = uiState.items[i]
val isDownloading = uiState.downloadingItemId == item.id
Box( Box(
modifier = Modifier modifier = Modifier.padding(4.dp).fillMaxWidth(),
.padding(4.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
BookItemCard( BookItemCard(
title = item.title, title = item.title,
author = item.author.ifBlank { item.authors.joinToString(", ") }, 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 }, coverImageUrl = item.coverUrl.ifBlank { null },
) { ) {
if (!isDownloading) viewModel.downloadAndOpen(item)
} }
} }
} }
@ -108,11 +94,6 @@ fun BooksScreen(navController: NavController) {
private fun formatSize(bytes: Long): String { private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "" if (bytes <= 0) return ""
val kb = bytes / 1024.0 val mb = bytes / 1048576.0
val mb = kb / 1024.0 return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0)
return when {
mb >= 1 -> String.format("%.1f MB", mb)
kb >= 1 -> String.format("%.0f KB", kb)
else -> "$bytes B"
}
} }

View file

@ -1,37 +1,45 @@
package org.dueattendant149.bookreader.ui.screens.books 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository 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 org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import java.io.File
import javax.inject.Inject import javax.inject.Inject
data class BooksUiState( data class BooksUiState(
val items: List<UnifiedItemResponse> = emptyList(), val items: List<UnifiedItemResponse> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val libraryId: String? = null, val downloadingItemId: String? = null,
) )
@HiltViewModel @HiltViewModel
class BooksViewModel class BooksViewModel
@Inject @Inject
constructor( constructor(
application: Application,
private val repository: BookshelfApiRepository, private val repository: BookshelfApiRepository,
) : ViewModel() { ) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(BooksUiState()) private val _uiState = MutableStateFlow(BooksUiState())
val uiState: StateFlow<BooksUiState> = _uiState.asStateFlow() val uiState: StateFlow<BooksUiState> = _uiState.asStateFlow()
init { private val _openBook = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
loadBooks() val openBook: SharedFlow<Uri> = _openBook.asSharedFlow()
}
init { loadBooks() }
fun loadBooks() { fun loadBooks() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null) _uiState.value = _uiState.value.copy(isLoading = true, error = null)
@ -39,41 +47,58 @@ class BooksViewModel
repository.getLibraries() repository.getLibraries()
.onSuccess { libraries -> .onSuccess { libraries ->
val booksLibrary = libraries.firstOrNull { val booksLibrary = libraries.firstOrNull {
it.name.equals("Books", ignoreCase = true) || it.id == "4e1152a6-1546-497a-b354-ccdd9cdbd32d" it.name.equals("Books", ignoreCase = true)
} ?: libraries.firstOrNull() } ?: libraries.firstOrNull()
if (booksLibrary != null) { if (booksLibrary != null) loadItems(booksLibrary.id)
loadItems(booksLibrary.id) else _uiState.value = _uiState.value.copy(isLoading = false, error = "No libraries")
} 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"
)
} }
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
} }
} }
private fun loadItems(libraryId: String) { private fun loadItems(libraryId: String) {
_uiState.value = _uiState.value.copy(libraryId = libraryId, isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch { viewModelScope.launch {
repository.getLibraryItems(libraryId) repository.getLibraryItems(libraryId)
.onSuccess { response -> .onSuccess { response ->
val ebooks = response.items.filter { it.type == "ebook" }
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
items = ebooks, items = response.items.filter { it.type == "ebook" },
isLoading = false, isLoading = false,
error = null,
) )
} }
.onFailure { error -> .onFailure { _uiState.value = _uiState.value.copy(isLoading = false, error = it.message) }
_uiState.value = _uiState.value.copy( }
isLoading = false, }
error = error.message ?: "Failed to load items"
) 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.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close