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