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:
parent
7411f1bfb5
commit
1b43c9c1ec
20 changed files with 1295 additions and 159 deletions
|
|
@ -226,6 +226,7 @@ dependencies {
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
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.material3.window.size.class1.android)
|
||||||
implementation(libs.androidx.credentials)
|
implementation(libs.androidx.credentials)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,6 @@ open class MainActivity : AppCompatActivity() {
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = uiState.appSeedColor == null,
|
dynamicColor = uiState.appSeedColor == null,
|
||||||
seedColor = uiState.appSeedColor,
|
seedColor = uiState.appSeedColor,
|
||||||
contrastLevel = uiState.appContrastOption.value,
|
|
||||||
textDimFactor = textDimFactor,
|
|
||||||
appFontFamily = appFontFamily
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
package org.dueattendant149.bookreader.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
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 onPrimaryLight = Color(0xFFFFFFFF)
|
||||||
val primaryContainerLight = Color(0xFFCDEDA3)
|
val primaryContainerLight = Color(0xFFBBEAFF)
|
||||||
val onPrimaryContainerLight = Color(0xFF354E16)
|
val onPrimaryContainerLight = Color(0xFF001F29)
|
||||||
val secondaryLight = Color(0xFF586249)
|
val secondaryLight = Color(0xFF4C616B)
|
||||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||||
val secondaryContainerLight = Color(0xFFDCE7C8)
|
val secondaryContainerLight = Color(0xFFCFE6F1)
|
||||||
val onSecondaryContainerLight = Color(0xFF404A33)
|
val onSecondaryContainerLight = Color(0xFF071E26)
|
||||||
val tertiaryLight = Color(0xFF386663)
|
val tertiaryLight = Color(0xFF5C5B7E)
|
||||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||||
val tertiaryContainerLight = Color(0xFFBCECE7)
|
val tertiaryContainerLight = Color(0xFFE2DFFF)
|
||||||
val onTertiaryContainerLight = Color(0xFF1F4E4B)
|
val onTertiaryContainerLight = Color(0xFF181837)
|
||||||
val errorLight = Color(0xFFBA1A1A)
|
val errorLight = Color(0xFFBA1A1A)
|
||||||
val onErrorLight = Color(0xFFFFFFFF)
|
val onErrorLight = Color(0xFFFFFFFF)
|
||||||
val errorContainerLight = Color(0xFFFFDAD6)
|
val errorContainerLight = Color(0xFFFFDAD6)
|
||||||
val onErrorContainerLight = Color(0xFF93000A)
|
val onErrorContainerLight = Color(0xFF410002)
|
||||||
val backgroundLight = Color(0xFFF9FAEF)
|
val backgroundLight = Color(0xFFF5FAFD)
|
||||||
val onBackgroundLight = Color(0xFF1A1C16)
|
val onBackgroundLight = Color(0xFF171C1F)
|
||||||
val surfaceLight = Color(0xFFF9FAEF)
|
val surfaceLight = Color(0xFFF5FAFD)
|
||||||
val onSurfaceLight = Color(0xFF1A1C16)
|
val onSurfaceLight = Color(0xFF171C1F)
|
||||||
val surfaceVariantLight = Color(0xFFE1E4D5)
|
val surfaceVariantLight = Color(0xFFDCE4E8)
|
||||||
val onSurfaceVariantLight = Color(0xFF44483D)
|
val onSurfaceVariantLight = Color(0xFF40484C)
|
||||||
val outlineLight = Color(0xFF75796C)
|
val outlineLight = Color(0xFF70787D)
|
||||||
val outlineVariantLight = Color(0xFFC5C8BA)
|
val outlineVariantLight = Color(0xFFC0C8CC)
|
||||||
val scrimLight = Color(0xFF000000)
|
val scrimLight = Color(0xFF000000)
|
||||||
val inverseSurfaceLight = Color(0xFF2F312A)
|
val inverseSurfaceLight = Color(0xFF2C3134)
|
||||||
val inverseOnSurfaceLight = Color(0xFFF1F2E6)
|
val inverseOnSurfaceLight = Color(0xFFEDF1F5)
|
||||||
val inversePrimaryLight = Color(0xFFB1D18A)
|
val inversePrimaryLight = Color(0xFF89D0EE)
|
||||||
val surfaceDimLight = Color(0xFFDADBD0)
|
val surfaceDimLight = Color(0xFFD6DBDE)
|
||||||
val surfaceBrightLight = Color(0xFFF9FAEF)
|
val surfaceBrightLight = Color(0xFFF5FAFD)
|
||||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||||
val surfaceContainerLowLight = Color(0xFFF3F4E9)
|
val surfaceContainerLowLight = Color(0xFFF0F4F7)
|
||||||
val surfaceContainerLight = Color(0xFFEEEFE3)
|
val surfaceContainerLight = Color(0xFFEAEEF2)
|
||||||
val surfaceContainerHighLight = Color(0xFFE8E9DE)
|
val surfaceContainerHighLight = Color(0xFFE4E9EC)
|
||||||
val surfaceContainerHighestLight = Color(0xFFE2E3D8)
|
val surfaceContainerHighestLight = Color(0xFFDEE3E6)
|
||||||
|
|
||||||
val primaryDark = Color(0xFFB1D18A)
|
// Dark Theme Colors
|
||||||
val onPrimaryDark = Color(0xFF1F3701)
|
val primaryDark = Color(0xFF89D0EE)
|
||||||
val primaryContainerDark = Color(0xFF354E16)
|
val onPrimaryDark = Color(0xFF003545)
|
||||||
val onPrimaryContainerDark = Color(0xFFCDEDA3)
|
val primaryContainerDark = Color(0xFF004D62)
|
||||||
val secondaryDark = Color(0xFFBFCBAD)
|
val onPrimaryContainerDark = Color(0xFFBBEAFF)
|
||||||
val onSecondaryDark = Color(0xFF2A331E)
|
val secondaryDark = Color(0xFFB4CAD5)
|
||||||
val secondaryContainerDark = Color(0xFF404A33)
|
val onSecondaryDark = Color(0xFF1E333C)
|
||||||
val onSecondaryContainerDark = Color(0xFFDCE7C8)
|
val secondaryContainerDark = Color(0xFF354A53)
|
||||||
val tertiaryDark = Color(0xFFA0D0CB)
|
val onSecondaryContainerDark = Color(0xFFCFE6F1)
|
||||||
val onTertiaryDark = Color(0xFF003735)
|
val tertiaryDark = Color(0xFFC5C3EA)
|
||||||
val tertiaryContainerDark = Color(0xFF1F4E4B)
|
val onTertiaryDark = Color(0xFF2D2D4D)
|
||||||
val onTertiaryContainerDark = Color(0xFFBCECE7)
|
val tertiaryContainerDark = Color(0xFF444465)
|
||||||
|
val onTertiaryContainerDark = Color(0xFFE2DFFF)
|
||||||
val errorDark = Color(0xFFFFB4AB)
|
val errorDark = Color(0xFFFFB4AB)
|
||||||
val onErrorDark = Color(0xFF690005)
|
val onErrorDark = Color(0xFF690005)
|
||||||
val errorContainerDark = Color(0xFF93000A)
|
val errorContainerDark = Color(0xFF93000A)
|
||||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||||
val backgroundDark = Color(0xFF12140E)
|
val backgroundDark = Color(0xFF0F1417)
|
||||||
val onBackgroundDark = Color(0xFFE2E3D8)
|
val onBackgroundDark = Color(0xFFDEE3E6)
|
||||||
val surfaceDark = Color(0xFF12140E)
|
val surfaceDark = Color(0xFF0F1417)
|
||||||
val onSurfaceDark = Color(0xFFE2E3D8)
|
val onSurfaceDark = Color(0xFFDEE3E6)
|
||||||
val surfaceVariantDark = Color(0xFF44483D)
|
val surfaceVariantDark = Color(0xFF40484C)
|
||||||
val onSurfaceVariantDark = Color(0xFFC5C8BA)
|
val onSurfaceVariantDark = Color(0xFFC0C8CC)
|
||||||
val outlineDark = Color(0xFF8F9285)
|
val outlineDark = Color(0xFF8A9296)
|
||||||
val outlineVariantDark = Color(0xFF44483D)
|
val outlineVariantDark = Color(0xFF40484C)
|
||||||
val scrimDark = Color(0xFF000000)
|
val scrimDark = Color(0xFF000000)
|
||||||
val inverseSurfaceDark = Color(0xFFE2E3D8)
|
val inverseSurfaceDark = Color(0xFFDEE3E6)
|
||||||
val inverseOnSurfaceDark = Color(0xFF2F312A)
|
val inverseOnSurfaceDark = Color(0xFF2C3134)
|
||||||
val inversePrimaryDark = Color(0xFF4C662B)
|
val inversePrimaryDark = Color(0xFF0D6681)
|
||||||
val surfaceDimDark = Color(0xFF12140E)
|
val surfaceDimDark = Color(0xFF0F1417)
|
||||||
val surfaceBrightDark = Color(0xFF383A32)
|
val surfaceBrightDark = Color(0xFF353A3D)
|
||||||
val surfaceContainerLowestDark = Color(0xFF0C0F09)
|
val surfaceContainerLowestDark = Color(0xFF0A0F11)
|
||||||
val surfaceContainerLowDark = Color(0xFF1A1C16)
|
val surfaceContainerLowDark = Color(0xFF171C1F)
|
||||||
val surfaceContainerDark = Color(0xFF1E201A)
|
val surfaceContainerDark = Color(0xFF1B2023)
|
||||||
val surfaceContainerHighDark = Color(0xFF282B24)
|
val surfaceContainerHighDark = Color(0xFF252B2D)
|
||||||
val surfaceContainerHighestDark = Color(0xFF33362E)
|
val surfaceContainerHighestDark = Color(0xFF303638)
|
||||||
|
|
@ -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
|
package org.dueattendant149.bookreader.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import com.materialkolor.PaletteStyle
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import org.dueattendant149.bookreader.shared.ui.withAppFontFamily
|
|
||||||
import com.materialkolor.dynamicColorScheme
|
|
||||||
|
|
||||||
private val lightScheme = lightColorScheme(
|
private val lightScheme = lightColorScheme(
|
||||||
primary = primaryLight,
|
primary = primaryLight,
|
||||||
|
|
@ -116,24 +93,13 @@ fun AppTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
seedColor: Color? = null,
|
seedColor: Color? = null,
|
||||||
contrastLevel: Double = 0.0,
|
content: @Composable () -> Unit,
|
||||||
textDimFactor: Float = 1.0f,
|
|
||||||
appFontFamily: FontFamily? = null,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val baseColorScheme = remember(
|
val colorScheme = remember(darkTheme, dynamicColor, supportsDynamicColor, seedColor) {
|
||||||
darkTheme, dynamicColor, supportsDynamicColor, seedColor, contrastLevel
|
|
||||||
) {
|
|
||||||
when {
|
when {
|
||||||
seedColor != null -> dynamicColorScheme(
|
|
||||||
seedColor = seedColor,
|
|
||||||
isDark = darkTheme,
|
|
||||||
contrastLevel = contrastLevel,
|
|
||||||
style = PaletteStyle.Fidelity
|
|
||||||
)
|
|
||||||
dynamicColor && supportsDynamicColor -> {
|
dynamicColor && supportsDynamicColor -> {
|
||||||
if (darkTheme) dynamicDarkColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context)
|
||||||
else dynamicLightColorScheme(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(
|
MaterialTheme(
|
||||||
colorScheme = finalColorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
typography = androidx.compose.material3.Typography(),
|
||||||
shapes = AppShapes,
|
shapes = AppShapes,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -32,9 +32,9 @@ class BookshelfApiRepository
|
||||||
) {
|
) {
|
||||||
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
||||||
|
|
||||||
suspend fun health(): Boolean {
|
suspend fun isServerAvailable(): Boolean {
|
||||||
val client = client() ?: return false
|
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>> {
|
suspend fun getLibraries(): Result<List<LibraryResponse>> {
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,6 @@ import retrofit2.http.POST
|
||||||
*/
|
*/
|
||||||
interface BookshelfApiService {
|
interface BookshelfApiService {
|
||||||
|
|
||||||
// Health
|
|
||||||
@GET("health")
|
|
||||||
suspend fun health(): Response<Unit>
|
|
||||||
|
|
||||||
// Libraries / books
|
// Libraries / books
|
||||||
@GET("api/v1/books/libraries")
|
@GET("api/v1/books/libraries")
|
||||||
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class ServerStatusMonitor
|
||||||
val url = serverSettings.getBookshelfUrl() ?: return false
|
val url = serverSettings.getBookshelfUrl() ?: return false
|
||||||
val client = clientFactory.provideClient(url) ?: return false
|
val client = clientFactory.provideClient(url) ?: return false
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val response = client.health()
|
val response = client.getLibraries()
|
||||||
response.isSuccessful
|
response.isSuccessful
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue