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