From 1b43c9c1eccf23cfd2ec798e482c633a8beb8d3c Mon Sep 17 00:00:00 2001 From: Atte149 Date: Sat, 27 Jun 2026 07:09:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Myne-style=20UI=20overhaul=20?= =?UTF-8?q?=E2=80=94=204=20tabs,=20API=20fixes,=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: API fixes - Removed health() endpoint (404 on server), replaced with getLibraries()-based check - ProgressUpdateRequest/PlaybackProgressUpdateRequest already have required fields - Fixed ServerStatusMonitor to use getLibraries() instead of health() - cover_url passes through as full URL (no resolveCoverUrl needed) Phase 2: Theme (Myne blue-teal palette) - Replaced Color.kt with Myne palette (primary #0D6681, dark #89D0EE) - Simplified Theme.kt: dynamic color (Android 12+), fallback to static, no custom fonts - Added material-icons-extended dependency Phase 3-4: Navigation + Common components - 4 tabs: Books, Audiobooks, Search, Settings - CustomBottomNavigationItem: pill-shaped, animated text, primary.copy(alpha=0.1f) - CustomTopAppBar: bold 28sp title, divider 2dp, circle back button - BookItemCard: horizontal card, cover + text, surfaceColorAtElevation(3dp), RoundedCornerShape(6dp) - ProgressDots: animated dots loader Phase 5-8: Screens - BooksScreen: loads ebooks from bookshelf-api, LazyVerticalGrid adaptive - AudiobooksScreen: loads audiobooks, shows duration - SearchScreen: OutlinedTextField with RoundedCornerShape(24dp), search via POST /api/v1/search - SettingsScreen: sections (Appearance, Server, About) with toggles and click items Phase 11: NavGraph - NavHost with 4 bottom bar routes ':app:assembleOssDebug' passes. APK deployed. --- app/build.gradle.kts | 1 + .../bookreader/reader/MainActivity.kt | 3 - .../bookreader/reader/ui/theme/Color.kt | 135 +++++++-------- .../bookreader/reader/ui/theme/Theme.kt | 80 +-------- .../bookshelfapi/BookshelfApiRepository.kt | 4 +- .../bookshelfapi/BookshelfApiService.kt | 4 - .../bookshelfapi/ServerStatusMonitor.kt | 2 +- .../bookreader/ui/MainScreen.kt | 121 +++++++++++++ .../bookreader/ui/common/BookItemCard.kt | 136 +++++++++++++++ .../bookreader/ui/common/ProgressDots.kt | 67 ++++++++ .../bookreader/ui/common/TopAppBars.kt | 117 +++++++++++++ .../ui/navigation/BottomBarScreen.kt | 26 +++ .../bookreader/ui/navigation/NavGraph.kt | 31 ++++ .../ui/screens/audiobooks/AudiobooksScreen.kt | 104 ++++++++++++ .../screens/audiobooks/AudiobooksViewModel.kt | 72 ++++++++ .../ui/screens/books/BooksScreen.kt | 118 +++++++++++++ .../ui/screens/books/BooksViewModel.kt | 79 +++++++++ .../ui/screens/search/SearchScreen.kt | 137 +++++++++++++++ .../ui/screens/search/SearchViewModel.kt | 57 +++++++ .../ui/screens/settings/SettingsScreen.kt | 160 ++++++++++++++++++ 20 files changed, 1295 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/MainScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/common/BookItemCard.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/common/ProgressDots.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/common/TopAppBars.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/navigation/BottomBarScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/settings/SettingsScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbfa2a8..8935236 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -226,6 +226,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) +implementation("androidx.compose.material:material-icons-extended:1.7.8") implementation(libs.androidx.material3.window.size.class1.android) implementation(libs.androidx.credentials) 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 fd5823e..340e852 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MainActivity.kt @@ -125,9 +125,6 @@ open class MainActivity : AppCompatActivity() { darkTheme = darkTheme, dynamicColor = uiState.appSeedColor == null, seedColor = uiState.appSeedColor, - contrastLevel = uiState.appContrastOption.value, - textDimFactor = textDimFactor, - appFontFamily = appFontFamily ) { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt index e69b93a..d369e50 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Color.kt @@ -1,94 +1,77 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ package org.dueattendant149.bookreader.ui.theme import androidx.compose.ui.graphics.Color -val primaryLight = Color(0xFF4C662B) +// Light Theme Colors (Myne blue-teal palette) +val primaryLight = Color(0xFF0D6681) val onPrimaryLight = Color(0xFFFFFFFF) -val primaryContainerLight = Color(0xFFCDEDA3) -val onPrimaryContainerLight = Color(0xFF354E16) -val secondaryLight = Color(0xFF586249) +val primaryContainerLight = Color(0xFFBBEAFF) +val onPrimaryContainerLight = Color(0xFF001F29) +val secondaryLight = Color(0xFF4C616B) val onSecondaryLight = Color(0xFFFFFFFF) -val secondaryContainerLight = Color(0xFFDCE7C8) -val onSecondaryContainerLight = Color(0xFF404A33) -val tertiaryLight = Color(0xFF386663) +val secondaryContainerLight = Color(0xFFCFE6F1) +val onSecondaryContainerLight = Color(0xFF071E26) +val tertiaryLight = Color(0xFF5C5B7E) val onTertiaryLight = Color(0xFFFFFFFF) -val tertiaryContainerLight = Color(0xFFBCECE7) -val onTertiaryContainerLight = Color(0xFF1F4E4B) +val tertiaryContainerLight = Color(0xFFE2DFFF) +val onTertiaryContainerLight = Color(0xFF181837) val errorLight = Color(0xFFBA1A1A) val onErrorLight = Color(0xFFFFFFFF) val errorContainerLight = Color(0xFFFFDAD6) -val onErrorContainerLight = Color(0xFF93000A) -val backgroundLight = Color(0xFFF9FAEF) -val onBackgroundLight = Color(0xFF1A1C16) -val surfaceLight = Color(0xFFF9FAEF) -val onSurfaceLight = Color(0xFF1A1C16) -val surfaceVariantLight = Color(0xFFE1E4D5) -val onSurfaceVariantLight = Color(0xFF44483D) -val outlineLight = Color(0xFF75796C) -val outlineVariantLight = Color(0xFFC5C8BA) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFF5FAFD) +val onBackgroundLight = Color(0xFF171C1F) +val surfaceLight = Color(0xFFF5FAFD) +val onSurfaceLight = Color(0xFF171C1F) +val surfaceVariantLight = Color(0xFFDCE4E8) +val onSurfaceVariantLight = Color(0xFF40484C) +val outlineLight = Color(0xFF70787D) +val outlineVariantLight = Color(0xFFC0C8CC) val scrimLight = Color(0xFF000000) -val inverseSurfaceLight = Color(0xFF2F312A) -val inverseOnSurfaceLight = Color(0xFFF1F2E6) -val inversePrimaryLight = Color(0xFFB1D18A) -val surfaceDimLight = Color(0xFFDADBD0) -val surfaceBrightLight = Color(0xFFF9FAEF) +val inverseSurfaceLight = Color(0xFF2C3134) +val inverseOnSurfaceLight = Color(0xFFEDF1F5) +val inversePrimaryLight = Color(0xFF89D0EE) +val surfaceDimLight = Color(0xFFD6DBDE) +val surfaceBrightLight = Color(0xFFF5FAFD) val surfaceContainerLowestLight = Color(0xFFFFFFFF) -val surfaceContainerLowLight = Color(0xFFF3F4E9) -val surfaceContainerLight = Color(0xFFEEEFE3) -val surfaceContainerHighLight = Color(0xFFE8E9DE) -val surfaceContainerHighestLight = Color(0xFFE2E3D8) +val surfaceContainerLowLight = Color(0xFFF0F4F7) +val surfaceContainerLight = Color(0xFFEAEEF2) +val surfaceContainerHighLight = Color(0xFFE4E9EC) +val surfaceContainerHighestLight = Color(0xFFDEE3E6) -val primaryDark = Color(0xFFB1D18A) -val onPrimaryDark = Color(0xFF1F3701) -val primaryContainerDark = Color(0xFF354E16) -val onPrimaryContainerDark = Color(0xFFCDEDA3) -val secondaryDark = Color(0xFFBFCBAD) -val onSecondaryDark = Color(0xFF2A331E) -val secondaryContainerDark = Color(0xFF404A33) -val onSecondaryContainerDark = Color(0xFFDCE7C8) -val tertiaryDark = Color(0xFFA0D0CB) -val onTertiaryDark = Color(0xFF003735) -val tertiaryContainerDark = Color(0xFF1F4E4B) -val onTertiaryContainerDark = Color(0xFFBCECE7) +// Dark Theme Colors +val primaryDark = Color(0xFF89D0EE) +val onPrimaryDark = Color(0xFF003545) +val primaryContainerDark = Color(0xFF004D62) +val onPrimaryContainerDark = Color(0xFFBBEAFF) +val secondaryDark = Color(0xFFB4CAD5) +val onSecondaryDark = Color(0xFF1E333C) +val secondaryContainerDark = Color(0xFF354A53) +val onSecondaryContainerDark = Color(0xFFCFE6F1) +val tertiaryDark = Color(0xFFC5C3EA) +val onTertiaryDark = Color(0xFF2D2D4D) +val tertiaryContainerDark = Color(0xFF444465) +val onTertiaryContainerDark = Color(0xFFE2DFFF) val errorDark = Color(0xFFFFB4AB) val onErrorDark = Color(0xFF690005) val errorContainerDark = Color(0xFF93000A) val onErrorContainerDark = Color(0xFFFFDAD6) -val backgroundDark = Color(0xFF12140E) -val onBackgroundDark = Color(0xFFE2E3D8) -val surfaceDark = Color(0xFF12140E) -val onSurfaceDark = Color(0xFFE2E3D8) -val surfaceVariantDark = Color(0xFF44483D) -val onSurfaceVariantDark = Color(0xFFC5C8BA) -val outlineDark = Color(0xFF8F9285) -val outlineVariantDark = Color(0xFF44483D) +val backgroundDark = Color(0xFF0F1417) +val onBackgroundDark = Color(0xFFDEE3E6) +val surfaceDark = Color(0xFF0F1417) +val onSurfaceDark = Color(0xFFDEE3E6) +val surfaceVariantDark = Color(0xFF40484C) +val onSurfaceVariantDark = Color(0xFFC0C8CC) +val outlineDark = Color(0xFF8A9296) +val outlineVariantDark = Color(0xFF40484C) val scrimDark = Color(0xFF000000) -val inverseSurfaceDark = Color(0xFFE2E3D8) -val inverseOnSurfaceDark = Color(0xFF2F312A) -val inversePrimaryDark = Color(0xFF4C662B) -val surfaceDimDark = Color(0xFF12140E) -val surfaceBrightDark = Color(0xFF383A32) -val surfaceContainerLowestDark = Color(0xFF0C0F09) -val surfaceContainerLowDark = Color(0xFF1A1C16) -val surfaceContainerDark = Color(0xFF1E201A) -val surfaceContainerHighDark = Color(0xFF282B24) -val surfaceContainerHighestDark = Color(0xFF33362E) \ No newline at end of file +val inverseSurfaceDark = Color(0xFFDEE3E6) +val inverseOnSurfaceDark = Color(0xFF2C3134) +val inversePrimaryDark = Color(0xFF0D6681) +val surfaceDimDark = Color(0xFF0F1417) +val surfaceBrightDark = Color(0xFF353A3D) +val surfaceContainerLowestDark = Color(0xFF0A0F11) +val surfaceContainerLowDark = Color(0xFF171C1F) +val surfaceContainerDark = Color(0xFF1B2023) +val surfaceContainerHighDark = Color(0xFF252B2D) +val surfaceContainerHighestDark = Color(0xFF303638) \ No newline at end of file diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt index daf31e4..4a5294c 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt @@ -1,39 +1,16 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ package org.dueattendant149.bookreader.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.lightColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import com.materialkolor.PaletteStyle import androidx.compose.ui.platform.LocalContext -import org.dueattendant149.bookreader.shared.ui.withAppFontFamily -import com.materialkolor.dynamicColorScheme private val lightScheme = lightColorScheme( primary = primaryLight, @@ -116,24 +93,13 @@ fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, seedColor: Color? = null, - contrastLevel: Double = 0.0, - textDimFactor: Float = 1.0f, - appFontFamily: FontFamily? = null, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val context = LocalContext.current - val baseColorScheme = remember( - darkTheme, dynamicColor, supportsDynamicColor, seedColor, contrastLevel - ) { + val colorScheme = remember(darkTheme, dynamicColor, supportsDynamicColor, seedColor) { when { - seedColor != null -> dynamicColorScheme( - seedColor = seedColor, - isDark = darkTheme, - contrastLevel = contrastLevel, - style = PaletteStyle.Fidelity - ) dynamicColor && supportsDynamicColor -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -143,42 +109,10 @@ fun AppTheme( } } - val finalColorScheme = remember(baseColorScheme, textDimFactor) { - if (textDimFactor >= 1.0f) { - baseColorScheme - } else { - baseColorScheme.copy( - primary = baseColorScheme.primary.copy(alpha = textDimFactor), - secondary = baseColorScheme.secondary.copy(alpha = textDimFactor), - tertiary = baseColorScheme.tertiary.copy(alpha = textDimFactor), - error = baseColorScheme.error.copy(alpha = textDimFactor), - primaryContainer = baseColorScheme.primaryContainer.copy(alpha = textDimFactor), - secondaryContainer = baseColorScheme.secondaryContainer.copy(alpha = textDimFactor), - tertiaryContainer = baseColorScheme.tertiaryContainer.copy(alpha = textDimFactor), - errorContainer = baseColorScheme.errorContainer.copy(alpha = textDimFactor), - outline = baseColorScheme.outline.copy(alpha = textDimFactor), - outlineVariant = baseColorScheme.outlineVariant.copy(alpha = textDimFactor), - inversePrimary = baseColorScheme.inversePrimary.copy(alpha = textDimFactor), - inverseOnSurface = baseColorScheme.inverseOnSurface.copy(alpha = textDimFactor), - onPrimary = baseColorScheme.onPrimary.copy(alpha = textDimFactor), - onSecondary = baseColorScheme.onSecondary.copy(alpha = textDimFactor), - onTertiary = baseColorScheme.onTertiary.copy(alpha = textDimFactor), - onBackground = baseColorScheme.onBackground.copy(alpha = textDimFactor), - onSurface = baseColorScheme.onSurface.copy(alpha = textDimFactor), - onSurfaceVariant = baseColorScheme.onSurfaceVariant.copy(alpha = textDimFactor), - onError = baseColorScheme.onError.copy(alpha = textDimFactor), - onPrimaryContainer = baseColorScheme.onPrimaryContainer.copy(alpha = textDimFactor), - onSecondaryContainer = baseColorScheme.onSecondaryContainer.copy(alpha = textDimFactor), - onTertiaryContainer = baseColorScheme.onTertiaryContainer.copy(alpha = textDimFactor), - onErrorContainer = baseColorScheme.onErrorContainer.copy(alpha = textDimFactor), - ) - } - } - MaterialTheme( - colorScheme = finalColorScheme, - typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography, + colorScheme = colorScheme, + typography = androidx.compose.material3.Typography(), shapes = AppShapes, - content = content + content = content, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt index 050c12f..e6b50dc 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt @@ -32,9 +32,9 @@ class BookshelfApiRepository ) { private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) } - suspend fun health(): Boolean { + suspend fun isServerAvailable(): Boolean { val client = client() ?: return false - return runCatching { client.health().isSuccessful }.getOrDefault(false) + return runCatching { client.getLibraries().isSuccessful }.getOrDefault(false) } suspend fun getLibraries(): Result> { diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt index 1541afe..a88d354 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt @@ -40,10 +40,6 @@ import retrofit2.http.POST */ interface BookshelfApiService { - // Health - @GET("health") - suspend fun health(): Response - // Libraries / books @GET("api/v1/books/libraries") suspend fun getLibraries(): Response> diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt index fec67b8..4a85b06 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt @@ -52,7 +52,7 @@ class ServerStatusMonitor val url = serverSettings.getBookshelfUrl() ?: return false val client = clientFactory.provideClient(url) ?: return false return runCatching { - val response = client.health() + val response = client.getLibraries() response.isSuccessful }.onFailure { Log.d("ServerStatusMonitor", "Health check failed: ${it.message}") diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/MainScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/MainScreen.kt new file mode 100644 index 0000000..413e83a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/MainScreen.kt @@ -0,0 +1,121 @@ +package org.dueattendant149.bookreader.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import org.dueattendant149.bookreader.ui.navigation.BottomBarScreen +import org.dueattendant149.bookreader.ui.navigation.NavGraph + +@Composable +fun MainScreen() { + val navController = rememberNavController() + Scaffold( + bottomBar = { BottomBar(navController) }, + containerColor = MaterialTheme.colorScheme.background, + ) { + NavGraph(navController = navController) + } +} + +@Composable +private fun BottomBar(navController: NavHostController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val bottomBarDestination = org.dueattendant149.bookreader.ui.navigation.bottomBarScreens.any { + it.route == currentDestination?.route + } + + AnimatedVisibility( + visible = bottomBarDestination, + modifier = Modifier.fillMaxWidth(), + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + .padding(12.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + org.dueattendant149.bookreader.ui.navigation.bottomBarScreens.forEach { screen -> + CustomBottomNavigationItem( + screen = screen, + isSelected = screen.route == currentDestination?.route, + ) { + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) + launchSingleTop = true + } + } + } + } + } +} + +@Composable +private fun CustomBottomNavigationItem( + screen: BottomBarScreen, + isSelected: Boolean, + onClick: () -> Unit, +) { + val background = + if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent + val contentColor = + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground + + androidx.compose.foundation.layout.Box( + modifier = Modifier + .clip(CircleShape) + .background(background) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = screen.icon, + contentDescription = screen.title, + tint = contentColor, + ) + AnimatedVisibility(visible = isSelected) { + Text( + text = screen.title, + color = contentColor, + fontSize = 14.sp, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/common/BookItemCard.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/common/BookItemCard.kt new file mode 100644 index 0000000..d0fafde --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/common/BookItemCard.kt @@ -0,0 +1,136 @@ +package org.dueattendant149.bookreader.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest + +@Composable +fun BookItemCard( + title: String, + author: String, + subtitle: String, + coverImageUrl: String?, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + ), + shape = RoundedCornerShape(6.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .heightIn(min = 180.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val imageBackground = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + } + + Box( + modifier = Modifier + .padding(10.dp) + .height(160.dp) + .aspectRatio(118f / 160f) + .clip(RoundedCornerShape(8.dp)) + .background(imageBackground) + ) { + if (!coverImageUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(coverImageUrl) + .crossfade(true) + .build(), + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(vertical = 10.dp) + .padding(end = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + modifier = Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth(), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (author.isNotBlank()) { + Text( + text = author, + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, top = 2.dp), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + lineHeight = 20.sp, + fontSize = 14.sp, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, top = 2.dp), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/common/ProgressDots.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/common/ProgressDots.kt new file mode 100644 index 0000000..57357a8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/common/ProgressDots.kt @@ -0,0 +1,67 @@ +package org.dueattendant149.bookreader.ui.common + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.unit.dp + +@Composable +fun ProgressDots() { + val transition = rememberInfiniteTransition(label = "dots") + val scales = (0..2).map { index -> + transition.animateFloat( + initialValue = 0.5f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(500, delayMillis = index * 150), + repeatMode = RepeatMode.Reverse, + ), + label = "scale_$index", + ) + } + val alphas = (0..2).map { index -> + transition.animateFloat( + initialValue = 0.3f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable( + animation = tween(500, delayMillis = index * 150), + repeatMode = RepeatMode.Reverse, + ), + label = "alpha_$index", + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + scales.forEachIndexed { i, scale -> + val s = scale.value + val a = alphas[i].value + Box( + modifier = Modifier + .size(10.dp) + .scale(s) + .alpha(a) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/common/TopAppBars.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/common/TopAppBars.kt new file mode 100644 index 0000000..e64b36e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/common/TopAppBars.kt @@ -0,0 +1,117 @@ +package org.dueattendant149.bookreader.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun CustomTopAppBar(headerText: String, icon: ImageVector? = null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.statusBars) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = headerText, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = androidx.compose.ui.Modifier + .padding(8.dp) + ) + } + } + HorizontalDivider( + thickness = 2.dp, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + } +} + +@Composable +fun CustomTopAppBar(headerText: String, onBackButtonClicked: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 8.dp) + .windowInsetsPadding(WindowInsets.statusBars) + ) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = headerText, + modifier = Modifier.padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TopBarActionItem( + icon = Icons.AutoMirrored.Filled.ArrowBack, + onclick = onBackButtonClicked + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } + } + HorizontalDivider( + thickness = 2.dp, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + } +} + +@Composable +private fun TopBarActionItem(icon: ImageVector, onclick: () -> Unit) { + Box( + modifier = Modifier + .padding(start = 16.dp, top = 2.dp, bottom = 18.dp, end = 16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)) + .clickable { onclick() } + ) { + Icon( + imageVector = icon, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(10.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/BottomBarScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/BottomBarScreen.kt new file mode 100644 index 0000000..9b5331f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/BottomBarScreen.kt @@ -0,0 +1,26 @@ +package org.dueattendant149.bookreader.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector + +sealed class BottomBarScreen( + val route: String, + val title: String, + val icon: ImageVector, +) { + data object Books : BottomBarScreen("books", "Books", Icons.Filled.Menu) + data object Audiobooks : BottomBarScreen("audiobooks", "Audiobooks", Icons.Filled.Audiotrack) + data object Search : BottomBarScreen("search", "Search", Icons.Filled.Search) + data object Settings : BottomBarScreen("settings", "Settings", Icons.Filled.Settings) +} + +val bottomBarScreens = listOf( + BottomBarScreen.Books, + BottomBarScreen.Audiobooks, + BottomBarScreen.Search, + BottomBarScreen.Settings, +) \ No newline at end of file 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 new file mode 100644 index 0000000..28e474c --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/navigation/NavGraph.kt @@ -0,0 +1,31 @@ +package org.dueattendant149.bookreader.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import org.dueattendant149.bookreader.ui.screens.audiobooks.AudiobooksScreen +import org.dueattendant149.bookreader.ui.screens.books.BooksScreen +import org.dueattendant149.bookreader.ui.screens.search.SearchScreen +import org.dueattendant149.bookreader.ui.screens.settings.SettingsScreen + +@Composable +fun NavGraph(navController: NavHostController) { + NavHost( + navController = navController, + startDestination = BottomBarScreen.Books.route, + ) { + composable(BottomBarScreen.Books.route) { + BooksScreen(navController = navController) + } + composable(BottomBarScreen.Audiobooks.route) { + AudiobooksScreen(navController = navController) + } + composable(BottomBarScreen.Search.route) { + SearchScreen(navController = navController) + } + composable(BottomBarScreen.Settings.route) { + SettingsScreen(navController = navController) + } + } +} \ 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 new file mode 100644 index 0000000..b631100 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksScreen.kt @@ -0,0 +1,104 @@ +package org.dueattendant149.bookreader.ui.screens.audiobooks + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import org.dueattendant149.bookreader.ui.common.BookItemCard +import org.dueattendant149.bookreader.ui.common.CustomTopAppBar +import org.dueattendant149.bookreader.ui.common.ProgressDots + +@Composable +fun AudiobooksScreen(navController: NavController) { + val viewModel: AudiobooksViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + CustomTopAppBar(headerText = "Audiobooks", icon = Icons.Filled.Audiotrack) + + 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) + } + } + + AnimatedVisibility( + visible = !uiState.isLoading && uiState.error == null, + enter = fadeIn(), + exit = fadeOut(), + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(start = 8.dp, end = 8.dp), + columns = GridCells.Adaptive(295.dp), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + items(uiState.items.size) { i -> + val item = uiState.items[i] + Box( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + BookItemCard( + title = item.title, + author = item.author.ifBlank { item.authors.joinToString(", ") }, + subtitle = formatDuration(item.duration), + coverImageUrl = item.coverUrl.ifBlank { null }, + ) { + } + } + } + } + } + } +} + +private fun formatDuration(seconds: Double): String { + if (seconds <= 0) return "" + val hours = (seconds / 3600).toInt() + val mins = ((seconds % 3600) / 60).toInt() + return when { + hours > 0 -> "${hours}h ${mins}m" + mins > 0 -> "${mins}m" + else -> "${seconds.toInt()}s" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt new file mode 100644 index 0000000..a1aee35 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudiobooksViewModel.kt @@ -0,0 +1,72 @@ +package org.dueattendant149.bookreader.ui.screens.audiobooks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse +import javax.inject.Inject + +data class AudiobooksUiState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class AudiobooksViewModel + @Inject + constructor( + private val repository: BookshelfApiRepository, + ) : ViewModel() { + + private val _uiState = MutableStateFlow(AudiobooksUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAudiobooks() + } + + fun loadAudiobooks() { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + viewModelScope.launch { + repository.getLibraries() + .onSuccess { libraries -> + val audioLibrary = libraries.firstOrNull { + it.name.equals("Audiobooks", ignoreCase = true) || it.id == "09bfdf43-cc5a-4352-9f12-35d4db2a8ed7" + } + if (audioLibrary != null) { + loadItems(audioLibrary.id) + } else { + _uiState.value = _uiState.value.copy(isLoading = false, error = "No audiobook library found") + } + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = error.message ?: "Failed to load libraries" + ) + } + } + } + + private fun loadItems(libraryId: String) { + viewModelScope.launch { + repository.getLibraryItems(libraryId) + .onSuccess { response -> + val audioItems = response.items.filter { it.type == "audiobook" || it.type == "podcast" } + _uiState.value = AudiobooksUiState(items = audioItems) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = error.message ?: "Failed to load items" + ) + } + } + } + } \ No newline at end of file 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 new file mode 100644 index 0000000..59afc07 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksScreen.kt @@ -0,0 +1,118 @@ +package org.dueattendant149.bookreader.ui.screens.books + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import org.dueattendant149.bookreader.ui.common.BookItemCard +import org.dueattendant149.bookreader.ui.common.CustomTopAppBar +import org.dueattendant149.bookreader.ui.common.ProgressDots + +@Composable +fun BooksScreen(navController: NavController) { + val viewModel: BooksViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + 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() + } + } + + uiState.error?.let { error -> + 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(), + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(start = 8.dp, end = 8.dp), + columns = GridCells.Adaptive(295.dp), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + items(uiState.items.size) { i -> + val item = uiState.items[i] + Box( + 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 "", + coverImageUrl = item.coverUrl.ifBlank { null }, + ) { + } + } + } + } + } + } +} + +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" + } +} \ 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 new file mode 100644 index 0000000..be916d0 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt @@ -0,0 +1,79 @@ +package org.dueattendant149.bookreader.ui.screens.books + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +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 javax.inject.Inject + +data class BooksUiState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val libraryId: String? = null, +) + +@HiltViewModel +class BooksViewModel + @Inject + constructor( + private val repository: BookshelfApiRepository, + ) : ViewModel() { + + private val _uiState = MutableStateFlow(BooksUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + 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" + } ?: 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" + ) + } + } + } + + private fun loadItems(libraryId: String) { + _uiState.value = _uiState.value.copy(libraryId = libraryId, isLoading = true) + viewModelScope.launch { + repository.getLibraryItems(libraryId) + .onSuccess { response -> + val ebooks = response.items.filter { it.type == "ebook" } + _uiState.value = _uiState.value.copy( + items = ebooks, + isLoading = false, + error = null, + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = error.message ?: "Failed to load items" + ) + } + } + } + } \ 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 new file mode 100644 index 0000000..20d4a09 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchScreen.kt @@ -0,0 +1,137 @@ +package org.dueattendant149.bookreader.ui.screens.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import org.dueattendant149.bookreader.ui.common.BookItemCard +import org.dueattendant149.bookreader.ui.common.CustomTopAppBar +import org.dueattendant149.bookreader.ui.common.ProgressDots + +@Composable +fun SearchScreen(navController: NavController) { + val viewModel: SearchViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + CustomTopAppBar(headerText = "Search", icon = Icons.Filled.Search) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .focusRequester(focusRequester), + value = uiState.query, + onValueChange = { viewModel.updateQuery(it) }, + placeholder = { Text("Search...") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") }, + trailingIcon = { + IconButton(onClick = { + if (uiState.query.isNotEmpty()) viewModel.updateQuery("") + }) { + Icon(Icons.Filled.Close, contentDescription = "Clear") + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { viewModel.search() }), + shape = RoundedCornerShape(24.dp), + ) + + if (uiState.isSearching) { + 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) + } + } + + if (!uiState.isSearching && uiState.hasSearched && uiState.results.isEmpty() && uiState.error == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "No results found", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + } + } + + if (!uiState.isSearching && uiState.results.isNotEmpty()) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(start = 8.dp, end = 8.dp), + columns = GridCells.Adaptive(295.dp), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + items(uiState.results.size) { i -> + val item = uiState.results[i] + Box( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + BookItemCard( + title = item.title, + author = item.author, + subtitle = "${item.source} · ${item.format}".trim(' ', '·'), + coverImageUrl = item.coverUrl.ifBlank { null }, + ) { + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt new file mode 100644 index 0000000..0a42a8a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/search/SearchViewModel.kt @@ -0,0 +1,57 @@ +package org.dueattendant149.bookreader.ui.screens.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResultItemResponse +import javax.inject.Inject + +data class SearchUiState( + val query: String = "", + val results: List = emptyList(), + val isSearching: Boolean = false, + val error: String? = null, + val hasSearched: Boolean = false, +) + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + private val repository: BookshelfApiRepository, + ) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateQuery(query: String) { + _uiState.value = _uiState.value.copy(query = query) + } + + fun search() { + val query = _uiState.value.query.trim() + if (query.isBlank()) return + _uiState.value = _uiState.value.copy(isSearching = true, error = null, hasSearched = true) + viewModelScope.launch { + repository.search(SearchRequest(query = query, limit = 50)) + .onSuccess { response -> + _uiState.value = _uiState.value.copy( + results = response.results, + isSearching = false, + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isSearching = false, + error = error.message ?: "Search failed", + ) + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..6adbd55 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,160 @@ +package org.dueattendant149.bookreader.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.navigation.NavController +import org.dueattendant149.bookreader.ui.common.CustomTopAppBar + +@Composable +fun SettingsScreen(navController: NavController) { + var dynamicColor by remember { mutableStateOf(true) } + var amoled by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(rememberScrollState()) + ) { + CustomTopAppBar(headerText = "Settings", icon = Icons.Filled.Settings) + + Spacer(modifier = Modifier.height(8.dp)) + + SettingsSection("Appearance") { + SettingsToggleItem( + title = "Material You", + subtitle = "Dynamic color from system wallpaper", + checked = dynamicColor, + onCheckedChange = { dynamicColor = it }, + ) + SettingsToggleItem( + title = "AMOLED", + subtitle = "Pure black background in dark mode", + checked = amoled, + onCheckedChange = { amoled = it }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSection("Server") { + SettingsClickItem( + title = "Server settings", + subtitle = "Bookshelf API & ABS URL/token", + ) { + navController.navigate("server_settings") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSection("About") { + SettingsClickItem( + title = "About", + subtitle = "Book Reader v1.0.50", + ) {} + } + + Spacer(modifier = Modifier.height(80.dp)) + } +} + +@Composable +private fun SettingsSection(title: String, content: @Composable () -> Unit) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + ), + ) { + Column { content() } + } +} + +@Composable +private fun SettingsToggleItem( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun SettingsClickItem( + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + Text(text = subtitle, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} \ No newline at end of file