feat(ui): Myne-style UI overhaul — 4 tabs, API fixes, theme

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.
This commit is contained in:
Atte149 2026-06-27 07:09:47 +03:00
parent 7411f1bfb5
commit 1b43c9c1ec
20 changed files with 1295 additions and 159 deletions

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*
* 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)
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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*
* 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,
)
}
}

View file

@ -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<List<LibraryResponse>> {

View file

@ -40,10 +40,6 @@ import retrofit2.http.POST
*/
interface BookshelfApiService {
// Health
@GET("health")
suspend fun health(): Response<Unit>
// Libraries / books
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<UnifiedItemResponse> = 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<AudiobooksUiState> = _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"
)
}
}
}
}

View file

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

View file

@ -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<UnifiedItemResponse> = 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<BooksUiState> = _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"
)
}
}
}
}

View file

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

View file

@ -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<SearchResultItemResponse> = 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<SearchUiState> = _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",
)
}
}
}
}

View file

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