diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt index 340e852..573538a 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt @@ -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() } } } diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt index 28e474c..33caadd 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt @@ -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() }) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt index b631100..3bdb590 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt @@ -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 diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt index 59afc07..e09e6d9 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt index be916d0..d187358 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() - init { - loadBooks() - } + private val _openBook = MutableSharedFlow(extraBufferCapacity = 1) + val openBook: SharedFlow = _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().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" + } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/reader/ReaderScreen.kt new file mode 100644 index 0000000..692ba17 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/reader/ReaderScreen.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt index 20d4a09..0bd85ea 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt @@ -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