Refactor screen/page navigation + thumbnail list.

This commit is contained in:
Thomas Andres Gomez 2022-04-21 18:21:36 +02:00
parent 5a1ff33a46
commit 0ee7de3cde
44 changed files with 1050 additions and 158 deletions

View file

@ -77,8 +77,6 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
@ -109,6 +107,7 @@ dependencies {
// Accompanist
implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.3-alpha"
implementation "com.google.accompanist:accompanist-insets:0.24.3-alpha"
implementation "com.google.accompanist:accompanist-drawablepainter:0.24.3-alpha"
// Navigation
implementation "androidx.navigation:navigation-compose:2.4.2"

View file

@ -4,15 +4,19 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.google.accompanist.insets.ProvideWindowInsets
import com.pixelized.biblib.ui.launch.LauncherViewModel
import com.pixelized.biblib.ui.navigation.FullScreenNavHost
import com.pixelized.biblib.ui.navigation.Screen
import com.google.accompanist.systemuicontroller.SystemUiController
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.pixelized.biblib.ui.composable.SystemThemeColor
import com.pixelized.biblib.ui.screen.launch.LauncherViewModel
import com.pixelized.biblib.ui.navigation.screen.ScreenNavHost
import com.pixelized.biblib.ui.navigation.screen.Screen
import com.pixelized.biblib.ui.theme.BibLibTheme
import dagger.hilt.android.AndroidEntryPoint
@ -40,9 +44,17 @@ class MainActivity : ComponentActivity() {
BibLibTheme {
ProvideWindowInsets {
Surface(color = MaterialTheme.colors.background) {
// handle the system colors
val systemUiController: SystemUiController = rememberSystemUiController()
SystemThemeColor(
systemUiController = systemUiController,
statusDarkIcons = isSystemInDarkTheme().not(),
navigationDarkIcons = isSystemInDarkTheme().not(),
)
// handle the main composition
val loading by launcherViewModel.isLoading
if (loading.not()) {
FullScreenNavHost(
ScreenNavHost(
startDestination = Screen.Authentication
)
}

View file

@ -1,15 +0,0 @@
package com.pixelized.biblib.ui.composable
import androidx.compose.runtime.Composable
@Composable
fun AnimatedDelayer(
content: @Composable AnimatedDelayerScope.() -> Unit
) {
val scope = AnimatedDelayerScope()
scope.content()
}
class AnimatedDelayerScope(
var delay: Delay = Delay()
)

View file

@ -0,0 +1,28 @@
package com.pixelized.biblib.ui.composable.animation
import androidx.compose.runtime.Composable
@Composable
fun AnimatedDelayer(
content: @Composable AnimatedDelayerScope.() -> Unit
) {
val scope = AnimatedDelayerScope()
scope.content()
}
class AnimatedDelayerScope(
var delay: Delay = Delay()
)
@JvmInline
value class Delay(
val value: Int = DELTA,
) {
operator fun inc(): Delay {
return Delay(value = value + DELTA)
}
companion object {
private const val DELTA = 100
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.composable
package com.pixelized.biblib.ui.composable.animation
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.Box
@ -115,19 +115,6 @@ private class TransitionData(
val offset by offset
}
@JvmInline
value class Delay(
val value: Int = DELTA,
) {
operator fun inc(): Delay {
return Delay(value = value + DELTA)
}
companion object {
private const val DELTA = 100
}
}
@Composable
private inline fun <reified T> rememberSavableMutableTransitionState(
initialState: T, targetState: T

View file

@ -35,8 +35,8 @@ fun ErrorCard(
modifier = Modifier
.padding(MaterialTheme.bibLib.dimen.medium)
.sizeIn(
minWidth = MaterialTheme.bibLib.dimen.dialog.minWidth,
minHeight = MaterialTheme.bibLib.dimen.dialog.minHeight,
minWidth = MaterialTheme.bibLib.dimen.dialog.minimum.width,
minHeight = MaterialTheme.bibLib.dimen.dialog.minimum.height,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -31,8 +31,8 @@ fun LoadingCard(
modifier = Modifier
.padding(MaterialTheme.bibLib.dimen.medium)
.sizeIn(
minWidth = MaterialTheme.bibLib.dimen.dialog.minWidth,
minHeight = MaterialTheme.bibLib.dimen.dialog.minHeight,
minWidth = MaterialTheme.bibLib.dimen.dialog.minimum.width,
minHeight = MaterialTheme.bibLib.dimen.dialog.minimum.height,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -32,8 +32,8 @@ fun SuccessCard(
modifier = Modifier
.padding(MaterialTheme.bibLib.dimen.medium)
.sizeIn(
minWidth = MaterialTheme.bibLib.dimen.dialog.minWidth,
minHeight = MaterialTheme.bibLib.dimen.dialog.minHeight,
minWidth = MaterialTheme.bibLib.dimen.dialog.minimum.width,
minHeight = MaterialTheme.bibLib.dimen.dialog.minimum.height,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -1,45 +0,0 @@
package com.pixelized.biblib.ui.navigation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.systemuicontroller.SystemUiController
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.pixelized.biblib.ui.authentication.AuthenticationScreen
import com.pixelized.biblib.ui.composable.SystemThemeColor
val LocalFullScreenNavHostController = compositionLocalOf<NavHostController> {
error("LocalFullScreenNavHostController is not ready yet.")
}
@Composable
fun FullScreenNavHost(
navHostController: NavHostController = rememberNavController(),
startDestination: Screen = Screen.Authentication
) {
val systemUiController: SystemUiController = rememberSystemUiController()
CompositionLocalProvider(LocalFullScreenNavHostController provides navHostController) {
NavHost(
navController = navHostController,
startDestination = startDestination.route,
) {
composable(Screen.Authentication.route) {
SystemThemeColor(
systemUiController = systemUiController,
statusDarkIcons = isSystemInDarkTheme().not(),
navigationDarkIcons = isSystemInDarkTheme().not(),
)
AuthenticationScreen()
}
composable(Screen.Home.route) {
}
}
}
}

View file

@ -0,0 +1,47 @@
package com.pixelized.biblib.ui.navigation.page
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.pixelized.biblib.R
sealed class Page(
val route: String,
val icon: ImageVector,
@StringRes val label: Int,
) {
object News : Page(
route = "news",
icon = Icons.Default.NewReleases,
label = R.string.menu_new,
)
object Books : Page(
route = "books",
icon = Icons.Default.AutoStories,
label = R.string.menu_book,
)
object Series : Page(
route = "series",
icon = Icons.Default.AutoAwesomeMotion,
label = R.string.menu_series,
)
object Author : Page(
route = "author",
icon = Icons.Default.SupervisorAccount,
label = R.string.menu_author,
)
object Tag : Page(
route = "tag",
icon = Icons.Default.LocalOffer,
label = R.string.menu_tag,
)
companion object {
val all = listOf(News, Books, Series, Author, Tag)
}
}

View file

@ -0,0 +1,47 @@
package com.pixelized.biblib.ui.navigation.page
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.pixelized.biblib.ui.screen.home.BooksPage
import com.pixelized.biblib.ui.screen.home.NewsPage
val LocalPageNavHostController = compositionLocalOf<NavHostController> {
error("LocalHomePageNavHostController is not ready yet.")
}
@Composable
fun PageNavHost(
modifier: Modifier = Modifier,
navHostController: NavHostController = rememberNavController(),
startDestination: Page = Page.News
) {
CompositionLocalProvider(LocalPageNavHostController provides navHostController) {
NavHost(
modifier = modifier,
navController = navHostController,
startDestination = startDestination.route,
) {
composable(Page.News.route) {
NewsPage()
}
composable(Page.Books.route) {
BooksPage()
}
composable(Page.Series.route) {
}
composable(Page.Author.route) {
}
composable(Page.Tag.route) {
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.navigation
package com.pixelized.biblib.ui.navigation.screen
sealed class Screen(
val route: String,

View file

@ -0,0 +1,38 @@
package com.pixelized.biblib.ui.navigation.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.pixelized.biblib.ui.screen.authentication.AuthenticationScreen
import com.pixelized.biblib.ui.screen.home.HomeScreen
val LocalScreenNavHostController = compositionLocalOf<NavHostController> {
error("LocalFullScreenNavHostController is not ready yet.")
}
@Composable
fun ScreenNavHost(
modifier: Modifier = Modifier,
navHostController: NavHostController = rememberNavController(),
startDestination: Screen = Screen.Authentication
) {
CompositionLocalProvider(LocalScreenNavHostController provides navHostController) {
NavHost(
modifier = modifier,
navController = navHostController,
startDestination = startDestination.route,
) {
composable(Screen.Authentication.route) {
AuthenticationScreen()
}
composable(Screen.Home.route) {
HomeScreen()
}
}
}
}

View file

@ -1,5 +1,7 @@
package com.pixelized.biblib.ui.old.composable.common
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CutCornerShape
@ -21,14 +23,14 @@ import androidx.compose.ui.unit.dp
import com.pixelized.biblib.BuildConfig
import com.pixelized.biblib.R
import com.pixelized.biblib.model.user.User
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.ui.old.viewmodel.user.IUserViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme
import java.util.*
@Composable
fun BibLibDrawer(
userViewModel : IUserViewModel,
userViewModel: IUserViewModel,
onNewClick: () -> Unit = {},
onBookClick: () -> Unit = {},
onSeriesClick: () -> Unit = {},
@ -95,7 +97,7 @@ fun BibLibDrawer(
style = typography.caption,
text = stringResource(
R.string.app_version,
BuildConfig.BUILD_TYPE.toUpperCase(Locale.getDefault()),
BuildConfig.BUILD_TYPE.uppercase(Locale.getDefault()),
BuildConfig.VERSION_NAME,
BuildConfig.VERSION_CODE
)
@ -106,30 +108,34 @@ fun BibLibDrawer(
@Composable
private fun DrawerItem(text: String, imageVector: ImageVector, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clickable { onClick() }) {
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.padding(horizontal = 16.dp),
imageVector = imageVector,
contentDescription = ""
contentDescription = null,
)
Text(
text = text,
)
Spacer(modifier = Modifier.weight(1f))
Spacer(
modifier = Modifier.weight(1f)
)
Icon(
modifier = Modifier.padding(end = 8.dp),
imageVector = Icons.Default.NavigateNext,
contentDescription = ""
contentDescription = null,
)
}
}
@Preview
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun BibLibDrawerLightPreview() {
BibLibTheme {
BibLibDrawer(

View file

@ -66,23 +66,6 @@ private fun download(
url: URL,
): State<Painter> {
val state = mutableStateOf(placeHolder)
val cache = EntryPointAccessors
.fromApplication(context, PersistenceModule::class.java)
.provideBitmapCache(context)
coroutineScope.launch {
val resource = cache.readFromDisk(url)?.let { BitmapPainter(it.asImageBitmap()) }
if (resource != null) {
state.value = resource
} else {
cache.download(url) { downloaded ->
if (downloaded != null) {
cache.writeToDisk(url, downloaded)
state.value = BitmapPainter(downloaded.asImageBitmap())
}
}
}
}
return state
}

View file

@ -1,6 +1,7 @@
package com.pixelized.biblib.ui.old.viewmodel.book
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
@ -14,6 +15,7 @@ import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.network.factory.BookFactory
import com.pixelized.biblib.repository.apiCache.IAPICacheRepository
import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.old.data.BookThumbnailUio
import com.pixelized.biblib.ui.old.data.BookUio
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -32,11 +32,11 @@ class NavigationViewModel : ViewModel(), INavigationViewModel {
override fun navigateBack(): Boolean {
return when (val navigable: Navigable? = popBackStack()) {
is Page -> {
_page.postValue(navigable)
// _page.postValue(navigable)
true
}
is Screen -> {
_screen.postValue(navigable)
// _screen.postValue(navigable)
true
}
else -> false

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.authentication
package com.pixelized.biblib.ui.screen.authentication
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.authentication
package com.pixelized.biblib.ui.screen.authentication
import android.content.Intent
import android.content.res.Configuration
@ -32,12 +32,12 @@ import androidx.navigation.NavHostController
import com.google.accompanist.insets.systemBarsPadding
import com.pixelized.biblib.R
import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.ui.composable.AnimatedDelayer
import com.pixelized.biblib.ui.composable.AnimatedOffset
import com.pixelized.biblib.ui.composable.animation.AnimatedDelayer
import com.pixelized.biblib.ui.composable.animation.AnimatedOffset
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.composable.StateUioHandler
import com.pixelized.biblib.ui.navigation.LocalFullScreenNavHostController
import com.pixelized.biblib.ui.navigation.Screen
import com.pixelized.biblib.ui.navigation.screen.LocalScreenNavHostController
import com.pixelized.biblib.ui.navigation.screen.Screen
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.ui.theme.color.GoogleColorPalette
import com.pixelized.biblib.utils.extention.bibLib
@ -46,7 +46,7 @@ import com.pixelized.biblib.utils.extention.bibLib
fun AuthenticationScreen(
viewModel: AuthenticationViewModel = hiltViewModel(),
) {
val navHostController = LocalFullScreenNavHostController.current
val navHostController = LocalScreenNavHostController.current
AuthenticationScreenContent(
login = viewModel.form.login,

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.authentication
package com.pixelized.biblib.ui.screen.authentication
import android.content.Intent
import android.util.Log

View file

@ -0,0 +1,29 @@
package com.pixelized.biblib.ui.screen.home
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.biblib.ui.screen.home.common.LazyBookThumbnailColumn
import com.pixelized.biblib.ui.screen.home.viewModel.BooksViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme
@Composable
fun BooksPage(
booksViewModel: BooksViewModel = hiltViewModel()
) {
LazyBookThumbnailColumn(
books = booksViewModel.books
)
}
@Composable
@Preview
private fun BooksPagePreview() {
BibLibTheme {
Column {
NewsPage()
}
}
}

View file

@ -0,0 +1,110 @@
package com.pixelized.biblib.ui.screen.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.insets.systemBarsPadding
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.navigation.page.Page
import com.pixelized.biblib.ui.navigation.page.PageNavHost
@Composable
fun HomeScreen() {
val pageNavHostController = rememberNavController()
Scaffold(
modifier = Modifier.systemBarsPadding(),
topBar = {
Column {
TopAppBar(
title = {
Text(text = stringResource(id = R.string.app_name))
}
)
BottomBarNavigation(
homePageNavController = pageNavHostController
)
}
},
content = {
PageNavHost(
modifier = Modifier.padding(it),
navHostController = pageNavHostController,
)
}
)
}
@Composable
private fun BottomBarNavigation(
homePageNavController: NavHostController,
pages: List<Page> = Page.all
) {
BottomNavigation(
backgroundColor = MaterialTheme.colors.background,
) {
val navBackStackEntry by homePageNavController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
pages.forEach { page ->
val selected = currentDestination?.hierarchy?.any { it.route == page.route }
BottomNavigationIcon(
page = page,
selected = selected ?: false,
onClick = {
homePageNavController.navigate(page.route) {
// Pop up to the start destination of the graph to avoid building up a
// large stack of destinations on the back stack as users select items
popUpTo(homePageNavController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when re-selecting the same item
launchSingleTop = true
// Restore state when re-selecting a previously selected item
restoreState = true
}
}
)
}
}
}
@Composable
fun RowScope.BottomNavigationIcon(
modifier: Modifier = Modifier,
page: Page,
selected: Boolean,
selectedColor: Color = MaterialTheme.colors.primary,
defaultColor: Color = MaterialTheme.colors.onSurface,
onClick: () -> Unit = {},
) {
BottomNavigationItem(
modifier = modifier,
icon = {
Icon(
imageVector = page.icon,
contentDescription = null,
)
},
label = {
Text(
style = MaterialTheme.typography.caption,
text = stringResource(id = page.label),
)
},
selectedContentColor = selectedColor,
unselectedContentColor = defaultColor,
selected = selected,
onClick = onClick,
)
}

View file

@ -0,0 +1,29 @@
package com.pixelized.biblib.ui.screen.home
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.biblib.ui.screen.home.common.LazyBookThumbnailColumn
import com.pixelized.biblib.ui.screen.home.viewModel.BooksViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme
@Composable
fun NewsPage(
booksViewModel: BooksViewModel = hiltViewModel()
) {
LazyBookThumbnailColumn(
books = booksViewModel.news
)
}
@Composable
@Preview
private fun NewPagePreview() {
BibLibTheme {
Column {
NewsPage()
}
}
}

View file

@ -0,0 +1,130 @@
package com.pixelized.biblib.ui.screen.home.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.screen.home.item.BookThumbnail
import com.pixelized.biblib.ui.screen.home.uio.BookThumbnailUio
import com.pixelized.biblib.ui.screen.home.uio.CoverUio
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import kotlinx.coroutines.flow.flowOf
@Composable
fun LazyBookThumbnailColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
books: LazyPagingItems<BookThumbnailUio>,
) {
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.bibLib.dimen.small),
contentPadding = PaddingValues(all = MaterialTheme.bibLib.dimen.small),
state = state,
) {
items(books) { thumbnail ->
BookThumbnail(thumbnail)
}
}
}
@Composable
@Preview
fun LazyBookThumbnailColumnPreview() {
BibLibTheme {
LazyBookThumbnailColumn(
modifier = Modifier.fillMaxSize(),
books = previewResources(),
)
}
}
@Composable
fun previewResources(): LazyPagingItems<BookThumbnailUio> {
val cover = CoverUio(
painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24),
contentScale = ContentScale.None,
)
val thumbnails = listOf(
BookThumbnailUio(
id = 112,
title = "Prélude à Fondation",
author = "Asimov",
date = "1988",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 78,
title = "L'Aube de Fondation",
author = "Asimov",
date = "1993",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 90,
title = "Fondation",
author = "Asimov",
date = "1951",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 184,
title = "Fondation et Empire",
author = "Asimov",
date = "1952",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 185,
title = "Seconde Fondation",
author = "Asimov",
date = "1953",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 119,
title = "Fondation foudroyée",
author = "Asimov",
date = "1982",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
BookThumbnailUio(
id = 163,
title = "Terre et Fondation",
author = "Asimov",
date = "1986",
genre = "Sci-Fi",
isNew = false,
cover = remember { mutableStateOf(cover) },
),
)
return flowOf(PagingData.from(thumbnails)).collectAsLazyPagingItems()
}

View file

@ -0,0 +1,133 @@
package com.pixelized.biblib.ui.screen.home.item
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.screen.home.uio.BookThumbnailUio
import com.pixelized.biblib.ui.screen.home.uio.CoverUio
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
@Composable
fun BookThumbnail(
thumbnail: BookThumbnailUio?,
onClick: (BookThumbnailUio) -> Unit = { },
) {
val currentOnClick by rememberUpdatedState(newValue = onClick)
Card(
modifier = Modifier
.clickable(enabled = thumbnail != null) {
thumbnail?.let { currentOnClick(it) }
}
.wrapContentHeight(),
) {
Row(
modifier = Modifier.height(MaterialTheme.bibLib.dimen.thumbnail.cover.height),
verticalAlignment = Alignment.CenterVertically,
) {
thumbnail?.cover?.let { it ->
Cover(
modifier = Modifier.size(MaterialTheme.bibLib.dimen.thumbnail.cover),
image = it,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.bibLib.dimen.small)
) {
Text(
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
text = thumbnail?.title ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface,
text = thumbnail?.author ?: ""
)
Spacer(modifier = Modifier.weight(1f))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface,
text = thumbnail?.genre ?: ""
)
Spacer(modifier = Modifier.weight(1f))
Text(
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface,
text = thumbnail?.date ?: ""
)
}
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun Cover(
modifier: Modifier = Modifier,
image: State<CoverUio>
) {
val cover by image
AnimatedContent(
targetState = cover,
transitionSpec = { fadeIn() with fadeOut() }
) {
Image(
modifier = modifier,
alignment = Alignment.Center,
contentScale = it.contentScale,
painter = it.painter,
contentDescription = null,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun BookThumbnailPreview() {
val painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24)
val thumbnail = BookThumbnailUio(
id = 0,
genre = "Sci-Fi",
title = "Foundation",
author = "Asimov",
date = "1951",
isNew = false,
cover = remember {
mutableStateOf(
CoverUio(
painter = painter,
contentScale = ContentScale.None,
)
)
},
)
BibLibTheme {
BookThumbnail(thumbnail = thumbnail)
}
}

View file

@ -0,0 +1,13 @@
package com.pixelized.biblib.ui.screen.home.uio
import androidx.compose.runtime.State
class BookThumbnailUio(
val id: Int,
val genre: String,
val title: String,
val author: String,
val date: String?,
val isNew: Boolean,
val cover: State<CoverUio>,
)

View file

@ -0,0 +1,17 @@
package com.pixelized.biblib.ui.screen.home.uio
import com.pixelized.biblib.network.client.IBibLibClient.Companion.COVER_URL
import java.net.URL
data class BookUio(
val id: Int,
val title: String,
val author: String,
val rating: Float,
val language: String,
val date: String?,
val series: String?,
val description: String,
) {
val cover: URL = URL("${COVER_URL}/$id.jpg")
}

View file

@ -0,0 +1,13 @@
package com.pixelized.biblib.ui.screen.home.uio
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
@Stable
@Immutable
data class CoverUio(
val contentScale: ContentScale = ContentScale.FillBounds,
val painter: Painter,
)

View file

@ -0,0 +1,208 @@
package com.pixelized.biblib.ui.screen.home.viewModel
import android.app.Application
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.drawablepainter.DrawablePainter
import com.pixelized.biblib.R
import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.network.factory.BookFactory
import com.pixelized.biblib.repository.apiCache.IAPICacheRepository
import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.screen.home.uio.BookThumbnailUio
import com.pixelized.biblib.ui.screen.home.uio.BookUio
import com.pixelized.biblib.ui.screen.home.uio.CoverUio
import com.pixelized.biblib.utils.BitmapCache
import com.pixelized.biblib.utils.extention.capitalize
import com.pixelized.biblib.utils.extention.context
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
/**
* TODO: there is some book related code that should be inside a Repository // DataSource.
*/
@HiltViewModel
class BooksViewModel @Inject constructor(
application: Application,
private val bookRepository: IBookRepository,
private val client: IBibLibClient,
private val apiCache: IAPICacheRepository,
private val cache: BitmapCache,
) : AndroidViewModel(application) {
private val formatterLong = SimpleDateFormat("MMMM yyyy", Locale.getDefault())
private val formatterShort = SimpleDateFormat("MMM yyyy", Locale.getDefault())
private val _state = mutableStateOf<StateUio<Boolean>?>(null)
val state: State<StateUio<Boolean>?> = _state
private val booksSource = Pager(
config = PagingConfig(pageSize = PAGING_SIZE),
pagingSourceFactory = bookRepository.getBooksSource()
.map { it.toThumbnail() }
.asPagingSourceFactory(Dispatchers.IO)
).flow
val books @Composable get() = booksSource.collectAsLazyPagingItems()
private val newsSource: Flow<PagingData<BookThumbnailUio>> = Pager(
config = PagingConfig(pageSize = PAGING_SIZE),
pagingSourceFactory = bookRepository.getNewsSource()
.map { it.toThumbnail() }
.asPagingSourceFactory(Dispatchers.IO)
).flow
val news @Composable get() = newsSource.collectAsLazyPagingItems()
fun updateBooks() {
viewModelScope.launch(Dispatchers.IO) {
_state.value = StateUio.Progress()
try {
val updated = loadNewBooks() && loadAllBooks()
_state.value = StateUio.Success(updated)
} catch (exception: Exception) {
Log.e("BooksViewModel", exception.message, exception)
_state.value = StateUio.Failure(exception = exception)
}
}
}
fun getBookDetail(id: Int): State<BookUio?> {
val data = mutableStateOf<BookUio?>(null)
viewModelScope.launch(Dispatchers.IO) {
val factory = BookFactory()
val response = client.service.detail(id)
val book = factory.fromDetailResponseToBook(response)
data.value = book.toUio()
}
return data
}
fun send(id: Int, mail: String) {
viewModelScope.launch(Dispatchers.IO) {
client.service.send(bookId = id, mail = mail)
}
}
private suspend fun loadNewBooks(): Boolean {
val cached = apiCache.new
val updated = client.service.new()
return if (cached != updated) {
apiCache.new = updated
true
} else {
false
}
}
private suspend fun loadAllBooks(): Boolean {
client.service.list().let { response ->
val newIds = apiCache.new?.data?.map { it.book_id } ?: listOf()
val factory = BookFactory()
val books = response.data?.map { dto ->
val isNew = newIds.contains(dto.book_id)
val index = newIds.indexOf(dto.book_id)
factory.fromListResponseToBook(dto, isNew, index)
}
books?.let { data -> bookRepository.update(data) }
}
return true
}
//////////////////////////////////////
// region: UIO conversion
private fun Book.toThumbnail() = BookThumbnailUio(
id = id,
genre = genre?.joinToString { it.name } ?: "",
title = title,
author = author.joinToString { it.name },
date = if (releaseDate.time < 0) {
null
} else {
formatterLong.format(releaseDate).capitalize()
},
isNew = isNew,
cover = cover(url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg"))
)
private fun Book.toUio() = BookUio(
id = id,
title = title,
author = author.joinToString { it.name },
rating = rating?.toFloat() ?: 0.0f,
language = language?.displayLanguage?.capitalize() ?: "",
date = if (releaseDate.time < 0) {
null
} else {
formatterShort.format(releaseDate).capitalize()
},
series = series?.name,
description = synopsis ?: "",
)
// endregion
private fun cover(
cache: BitmapCache = this.cache,
placeHolder: Painter = DrawablePainter(
drawable = AppCompatResources.getDrawable(
context,
R.drawable.ic_baseline_auto_stories_24
)!!
),
coroutineScope: CoroutineScope = viewModelScope,
url: URL,
): State<CoverUio> {
if (cache.exist(url)) {
val bitmap = cache.readFromDisk(url) ?: throw RuntimeException("")
val resource = BitmapPainter(bitmap.asImageBitmap())
return mutableStateOf(CoverUio(painter = resource))
} else {
val state = mutableStateOf(
CoverUio(
contentScale = ContentScale.None,
painter = placeHolder
)
)
coroutineScope.launch {
val resource = cache.readFromDisk(url)?.let { BitmapPainter(it.asImageBitmap()) }
if (resource != null) {
state.value = CoverUio(painter = resource)
} else {
val downloaded = cache.download(url)
if (downloaded != null) {
cache.writeToDisk(url, downloaded)
state.value = CoverUio(painter = BitmapPainter(downloaded.asImageBitmap()))
}
}
}
return state
}
}
companion object {
private const val PAGING_SIZE = 30
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.launch
package com.pixelized.biblib.ui.screen.launch
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf

View file

@ -5,6 +5,8 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import com.pixelized.biblib.ui.theme.color.BibLibColor
import com.pixelized.biblib.ui.theme.dimen.BibLibDimen
import com.pixelized.biblib.ui.theme.shape.BibLibShape
import com.pixelized.biblib.ui.theme.typography.BibLibTypography
val LocalBibLibTheme = compositionLocalOf<BibLibTheme> {
error("BibLibTheme not ready yet.")
@ -13,6 +15,8 @@ val LocalBibLibTheme = compositionLocalOf<BibLibTheme> {
@Stable
@Immutable
data class BibLibTheme(
val dimen: BibLibDimen,
val color: BibLibColor,
val dimen: BibLibDimen,
val typography: BibLibTypography,
val shape: BibLibShape,
)

View file

@ -7,8 +7,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import com.pixelized.biblib.ui.theme.color.bibLibDarkColors
import com.pixelized.biblib.ui.theme.color.bibLibLightColors
import com.pixelized.biblib.ui.theme.dimen.BibLibDimen
import com.pixelized.biblib.ui.theme.shape.Shapes
import com.pixelized.biblib.ui.theme.typography.Typography
import com.pixelized.biblib.ui.theme.shape.BibLibShape
import com.pixelized.biblib.ui.theme.typography.BibLibTypography
@Composable
fun BibLibTheme(
@ -16,15 +16,16 @@ fun BibLibTheme(
content: @Composable () -> Unit
) {
val theme = BibLibTheme(
color = if (darkTheme) bibLibDarkColors() else bibLibLightColors(),
dimen = BibLibDimen(),
color = if (darkTheme) bibLibDarkColors() else bibLibLightColors()
typography = BibLibTypography(),
shape = BibLibShape(),
)
CompositionLocalProvider(LocalBibLibTheme provides theme) {
MaterialTheme(
colors = theme.color.base,
typography = Typography,
shapes = Shapes,
typography = theme.typography.base,
shapes = theme.shape.base,
content = content
)
}

View file

@ -3,6 +3,7 @@ package com.pixelized.biblib.ui.theme.dimen
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@Stable
@ -14,14 +15,21 @@ data class BibLibDimen(
val medium: Dp = 16.dp,
val large: Dp = 32.dp,
val extraLarge: Dp = 64.dp,
val dialog: Dialog = Dialog()
val dialog: Dialog = Dialog(),
val thumbnail: BookThumbnail = BookThumbnail(),
) {
@Stable
@Immutable
data class Dialog(
val minimum: DpSize = DpSize(240.dp, 120.dp),
val elevation: Dp = 4.dp,
val minWidth: Dp = 240.dp,
val minHeight: Dp = 120.dp,
val iconSize: Dp = 52.dp,
)
@Stable
@Immutable
data class BookThumbnail(
val cover: DpSize = DpSize(60.dp, 96.dp),
val corner: Dp = 8.dp,
)
}

View file

@ -0,0 +1,18 @@
package com.pixelized.biblib.ui.theme.shape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Stable
@Immutable
data class BibLibShape(
val base: Shapes = Shapes(
small = RoundedCornerShape(50),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp),
),
)

View file

@ -1,11 +0,0 @@
package com.pixelized.biblib.ui.theme.shape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(50),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)

View file

@ -0,0 +1,11 @@
package com.pixelized.biblib.ui.theme.typography
import androidx.compose.material.Typography
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@Stable
@Immutable
data class BibLibTypography(
val base: Typography = Typography()
)

View file

@ -1,5 +0,0 @@
package com.pixelized.biblib.ui.theme.typography
import androidx.compose.material.Typography
val Typography = Typography()

View file

@ -9,13 +9,13 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.net.URL
import javax.inject.Inject
class BitmapCache(context: Context) {
class BitmapCache @Inject constructor(context: Context) {
private var cache: File? = context.cacheDir
fun writeToDisk(url: URL, bitmap: Bitmap) {
val path = cache?.absolutePath + url.file
val file = File(path)
val file = file(url)
try {
file.mkdirs()
file.delete()
@ -26,8 +26,7 @@ class BitmapCache(context: Context) {
}
fun readFromDisk(url: URL): Bitmap? {
val path = cache?.absolutePath + url.file
val file = File(path)
val file = file(url)
return try {
BitmapFactory.decodeStream(file.inputStream())
} catch (e: IOException) {
@ -35,8 +34,14 @@ class BitmapCache(context: Context) {
}
}
suspend fun download(url: URL, callback: suspend (Bitmap?) -> Unit) {
val bitmap = withContext(Dispatchers.IO) {
fun exist(url: URL): Boolean {
val file = file(url)
return file.exists()
}
suspend fun download(url: URL): Bitmap? {
Log.v("BitmapCache","download: $url")
return withContext(Dispatchers.IO) {
try {
BitmapFactory.decodeStream(url.openStream())
} catch (e: IOException) {
@ -44,6 +49,7 @@ class BitmapCache(context: Context) {
null
}
}
callback.invoke(bitmap)
}
private fun file(url: URL): File = File(cache?.absolutePath + url.file)
}

View file

@ -0,0 +1,6 @@
package com.pixelized.biblib.utils.extention
import android.content.Context
import androidx.lifecycle.AndroidViewModel
val AndroidViewModel.context: Context get() = this.getApplication()

View file

@ -0,0 +1,7 @@
package com.pixelized.biblib.utils.extention
import java.util.*
fun String.capitalize() = this.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,1l-5,5v11l5,-4.5L19,1zM1,6v14.65c0,0.25 0.25,0.5 0.5,0.5 0.1,0 0.15,-0.05 0.25,-0.05C3.1,20.45 5.05,20 6.5,20c1.95,0 4.05,0.4 5.5,1.5L12,6c-1.45,-1.1 -3.55,-1.5 -5.5,-1.5S2.45,4.9 1,6zM23,19.5L23,6c-0.6,-0.45 -1.25,-0.75 -2,-1v13.5c-1.1,-0.35 -2.3,-0.5 -3.5,-0.5 -1.7,0 -4.15,0.65 -5.5,1.5v2c1.35,-0.85 3.8,-1.5 5.5,-1.5 1.65,0 3.35,0.3 4.75,1.05 0.1,0.05 0.15,0.05 0.25,0.05 0.25,0 0.5,-0.25 0.5,-0.5v-1.1z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M19,1l-5,5v11l5,-4.5L19,1zM1,6v14.65c0,0.25 0.25,0.5 0.5,0.5 0.1,0 0.15,-0.05 0.25,-0.05C3.1,20.45 5.05,20 6.5,20c1.95,0 4.05,0.4 5.5,1.5L12,6c-1.45,-1.1 -3.55,-1.5 -5.5,-1.5S2.45,4.9 1,6zM23,19.5L23,6c-0.6,-0.45 -1.25,-0.75 -2,-1v13.5c-1.1,-0.35 -2.3,-0.5 -3.5,-0.5 -1.7,0 -4.15,0.65 -5.5,1.5v2c1.35,-0.85 3.8,-1.5 5.5,-1.5 1.65,0 3.35,0.3 4.75,1.05 0.1,0.05 0.15,0.05 0.25,0.05 0.25,0 0.5,-0.25 0.5,-0.5v-1.1z" />
</vector>

View file

@ -0,0 +1,50 @@
<resources>
<string name="app_name">BibLib</string>
<string name="app_version">%1$s: %2$s - %3$d</string>
<!-- Actions -->
<string name="action_register">Register</string>
<string name="action_login">Login</string>
<string name="action_epub">EPUB</string>
<string name="action_mobi">MOBI</string>
<string name="action_send">SEND</string>
<string name="action_google_sign_in">Sign in with</string>
<!-- Bottom bar -->
<!-- <string name="menu_new">Nouveautés</string>-->
<!-- <string name="menu_book">Livres</string>-->
<!-- <string name="menu_series">Séries</string>-->
<!-- <string name="menu_author">Auteurs</string>-->
<!-- <string name="menu_tag">Étiquettes</string>-->
<!-- Dialogs -->
<string name="error_generic">Oops!</string>
<string name="error_authentication">Oops, connection failed!</string>
<string name="error_book">Oops! library download failed!</string>
<string name="loading_authentication">Entering the Imperial Library of Trantor.</string>
<string name="loading_book">Downloading the Imperial Library of Trantor.</string>
<string name="success_authentication">Authentication successful</string>
<string name="success_book">Library successfully loaded</string>
<!-- Screens & pages -->
<string name="splash_welcome">Welcome to</string>
<string name="authentication_title">Sign in to BibLib</string>
<string name="authentication_login">Login</string>
<string name="authentication_password">Password</string>
<string name="authentication_credential_remember">Remember my credential</string>
<string name="detail_rating">Rating</string>
<string name="detail_language">Language</string>
<string name="detail_release">Release</string>
<string name="detail_genre">Genre</string>
<string name="detail_series">Series</string>
<string name="not_implemented_yet">Not implemented yet.</string>
</resources>

View file

@ -11,6 +11,14 @@
<string name="action_send">SEND</string>
<string name="action_google_sign_in">Sign in with</string>
<!-- Menu item -->
<string name="menu_new">News</string>
<string name="menu_book">Books</string>
<string name="menu_series">Series</string>
<string name="menu_author">Authors</string>
<string name="menu_tag">tags</string>
<!-- Dialogs -->
<string name="error_generic">Oops!</string>