Detail Screen

This commit is contained in:
Thomas Andres Gomez 2022-04-24 11:46:47 +02:00
parent e6d7694f67
commit c855e97c34
15 changed files with 475 additions and 183 deletions

View file

@ -1,11 +1,10 @@
package com.pixelized.biblib.module package com.pixelized.biblib.module
import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.room.Room import androidx.room.Room
import com.pixelized.biblib.database.BibLibDatabase import com.pixelized.biblib.database.BibLibDatabase
import com.pixelized.biblib.utils.BitmapCache import com.pixelized.biblib.utils.CoverCache
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -34,8 +33,8 @@ class PersistenceModule {
@Singleton @Singleton
fun provideBitmapCache( fun provideBitmapCache(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): BitmapCache { ): CoverCache {
return BitmapCache(context) return CoverCache(context)
} }
@Provides @Provides

View file

@ -0,0 +1,99 @@
package com.pixelized.biblib.ui.composable
import android.graphics.Typeface
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
@Composable
fun SpannedText(
text: Spanned,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
androidx.compose.material.Text(
text = text.toAnnotatedString(),
modifier = modifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
inlineContent = inlineContent,
onTextLayout = onTextLayout,
style = style,
)
}
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
val spanned = this@toAnnotatedString
append(spanned.toString())
getSpans(0, spanned.length, Any::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD -> addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = start,
end = end
)
Typeface.ITALIC -> addStyle(
style = SpanStyle(fontStyle = FontStyle.Italic),
start = start,
end = end
)
Typeface.BOLD_ITALIC -> addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
start = start,
end = end
)
}
is UnderlineSpan -> addStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
start = start,
end = end
)
is ForegroundColorSpan -> addStyle(
style = SpanStyle(color = Color(span.foregroundColor)),
start = start,
end = end
)
}
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@ -22,12 +23,14 @@ import androidx.compose.ui.unit.dp
fun AnimatedDelayerScope.AnimatedOffset( fun AnimatedDelayerScope.AnimatedOffset(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
transitionLabel: String = "AnimatedOffset", transitionLabel: String = "AnimatedOffset",
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
AnimatedOffset( AnimatedOffset(
modifier = modifier, modifier = modifier,
transitionLabel = transitionLabel, transitionLabel = transitionLabel,
delay = delay++, delay = delay++,
contentAlignment = contentAlignment,
content = content, content = content,
) )
} }
@ -37,6 +40,7 @@ fun AnimatedOffset(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
transitionLabel: String = "AnimatedOffset", transitionLabel: String = "AnimatedOffset",
delay: Delay = Delay(), delay: Delay = Delay(),
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val displayed = rememberSavableMutableTransitionState( val displayed = rememberSavableMutableTransitionState(
@ -48,6 +52,7 @@ fun AnimatedOffset(
transitionLabel = transitionLabel, transitionLabel = transitionLabel,
displayed = displayed, displayed = displayed,
delay = delay, delay = delay,
contentAlignment = contentAlignment,
content = content, content = content,
) )
} }
@ -58,6 +63,7 @@ fun AnimatedOffset(
displayed: MutableTransitionState<Boolean>, displayed: MutableTransitionState<Boolean>,
transitionLabel: String = "AnimatedOffset", transitionLabel: String = "AnimatedOffset",
delay: Delay = Delay(), delay: Delay = Delay(),
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val transition: TransitionData = updateTransition( val transition: TransitionData = updateTransition(
@ -69,6 +75,7 @@ fun AnimatedOffset(
modifier = modifier modifier = modifier
.offset(y = transition.offset) .offset(y = transition.offset)
.graphicsLayer(alpha = transition.alpha, clip = false), .graphicsLayer(alpha = transition.alpha, clip = false),
contentAlignment = contentAlignment,
content = content content = content
) )
} }

View file

@ -1,30 +1,41 @@
package com.pixelized.biblib.ui.screen.detail package com.pixelized.biblib.ui.screen.detail
import android.app.Application
import android.util.Log import android.util.Log
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.pixelized.biblib.R
import com.pixelized.biblib.model.book.Book import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.network.client.IBibLibClient import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.network.factory.BookFactory import com.pixelized.biblib.network.factory.BookFactory
import com.pixelized.biblib.ui.composable.StateUio import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.navigation.screen.Screen import com.pixelized.biblib.ui.navigation.screen.Screen
import com.pixelized.biblib.ui.screen.home.common.uio.BookUio import com.pixelized.biblib.ui.screen.home.common.uio.BookUio
import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import com.pixelized.biblib.ui.screen.home.common.viewModel.ACoverViewModel
import com.pixelized.biblib.utils.CoverCache
import com.pixelized.biblib.utils.extention.capitalize import com.pixelized.biblib.utils.extention.capitalize
import com.pixelized.biblib.utils.extention.context
import com.pixelized.biblib.utils.extention.shortDate import com.pixelized.biblib.utils.extention.shortDate
import com.pixelized.biblib.utils.painterResource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URL
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookDetailViewModel @Inject constructor( class BookDetailViewModel @Inject constructor(
application: Application,
coverCache: CoverCache,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val client: IBibLibClient, private val client: IBibLibClient,
) : ViewModel() { ) : ACoverViewModel(application, coverCache) {
private val _state = mutableStateOf<StateUio<BookUio>>(StateUio.Progress()) private val _state = mutableStateOf<StateUio<BookUio>>(StateUio.Progress())
val state: State<StateUio<BookUio>> get() = _state val state: State<StateUio<BookUio>> get() = _state
@ -53,16 +64,34 @@ class BookDetailViewModel @Inject constructor(
return book.toUio() return book.toUio()
} }
private fun Book.toUio() = BookUio( private fun Book.toUio() : BookUio {
id = id, val thumbnailCover by cover(
title = title, placeHolder = CoverUio(
author = author.joinToString { it.name }, type = CoverUio.Type.PLACE_HOLDER,
rating = rating?.toFloat() ?: 0.0f, contentScale = ContentScale.FillBounds,
language = language?.displayLanguage?.capitalize() ?: "", painter = painterResource(context, R.drawable.ic_baseline_auto_stories_24),
date = releaseDate.shortDate(), ),
series = series?.name, type = CoverUio.Type.THUMBNAIL,
description = synopsis ?: "", contentScale = ContentScale.FillHeight,
) url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg"),
)
return BookUio(
id = id,
title = title,
author = author.joinToString { it.name },
rating = rating?.toFloat() ?: 0.0f,
language = language?.displayLanguage?.capitalize() ?: "",
date = releaseDate.shortDate(),
series = series?.name,
description = synopsis ?: "",
cover = cover(
placeHolder = thumbnailCover,
type = CoverUio.Type.DETAIL,
contentScale = ContentScale.FillHeight,
url = URL("${IBibLibClient.COVER_URL}/$id.jpg"),
)
)
}
private val SavedStateHandle.bookId: Int private val SavedStateHandle.bookId: Int
get() = get<Int>(Screen.BookDetail.ARG_BOOK_ID) ?: error("") get() = get<Int>(Screen.BookDetail.ARG_BOOK_ID) ?: error("")

View file

@ -2,94 +2,175 @@ package com.pixelized.biblib.ui.screen.detail
import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.widget.TextView import androidx.activity.compose.BackHandler
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.toSpannable
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.insets.systemBarsPadding
import com.pixelized.biblib.R import com.pixelized.biblib.R
import com.pixelized.biblib.ui.composable.SpannedText
import com.pixelized.biblib.ui.composable.animation.AnimatedDelayer import com.pixelized.biblib.ui.composable.animation.AnimatedDelayer
import com.pixelized.biblib.ui.composable.animation.AnimatedOffset import com.pixelized.biblib.ui.composable.animation.AnimatedOffset
import com.pixelized.biblib.ui.composable.animation.Delay import com.pixelized.biblib.ui.composable.animation.Delay
import com.pixelized.biblib.ui.navigation.screen.LocalScreenNavHostController
import com.pixelized.biblib.ui.screen.home.common.uio.BookUio import com.pixelized.biblib.ui.screen.home.common.uio.BookUio
import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import com.pixelized.biblib.ui.theme.BibLibTheme import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
@Composable @Composable
fun DetailScreen( fun DetailScreen(
viewModel: BookDetailViewModel = hiltViewModel() viewModel: BookDetailViewModel = hiltViewModel()
) { ) {
val screenNavHostController = LocalScreenNavHostController.current
Box { Box {
val book by viewModel.book val book by viewModel.book
book?.let { book?.let {
DetailScreenContent( DetailScreenContent(
book = it, book = it,
onSendClick = {}, onClose = {
screenNavHostController.popBackStack()
},
onMobi = {},
onEpub = {},
onSend = {},
) )
} }
} }
} }
@OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
private fun DetailScreenContent( private fun DetailScreenContent(
modifier: Modifier = Modifier,
book: BookUio, book: BookUio,
onSendClick: () -> Unit, onClose: () -> Unit,
onMobi: () -> Unit,
onEpub: () -> Unit,
onSend: () -> Unit,
) { ) {
AnimatedDelayer(delay = Delay(300)) { AnimatedDelayer(delay = Delay(300)) {
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.systemBarsPadding() .systemBarsPadding()
.padding(horizontal = 16.dp) .padding(horizontal = MaterialTheme.bibLib.dimen.medium)
.then(modifier)
) { ) {
AnimatedOffset( AnimatedOffset(
modifier = Modifier modifier = Modifier.align(Alignment.End),
.padding(16.dp)
.align(Alignment.CenterHorizontally),
) { ) {
Box( IconButton(onClick = onClose) {
modifier = Modifier Icon(
.size(200.dp, 320.dp) imageVector = Icons.Default.Close,
.background(MaterialTheme.colors.surface), tint = MaterialTheme.colors.onSurface,
) { contentDescription = null
Box(contentAlignment = Alignment.Center) { )
}
}
AnimatedOffset(
modifier = Modifier
.fillMaxWidth()
.height(MaterialTheme.bibLib.dimen.detail.cover),
contentAlignment = Alignment.Center
) {
val cover by book.cover
when (cover.type) {
CoverUio.Type.PLACE_HOLDER -> {
Image( Image(
modifier = Modifier.size(64.dp), modifier = Modifier.size(MaterialTheme.bibLib.dimen.detail.placeHolder),
painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24), painter = cover.painter,
contentScale = cover.contentScale,
colorFilter = cover.colorFilter,
contentDescription = null,
)
}
CoverUio.Type.THUMBNAIL -> {
Image(
modifier = Modifier.fillMaxSize(),
painter = cover.painter,
contentScale = cover.contentScale,
colorFilter = cover.colorFilter,
contentDescription = null,
)
}
CoverUio.Type.DETAIL -> {
Image(
modifier = Modifier.fillMaxSize(),
painter = cover.painter,
contentScale = cover.contentScale,
colorFilter = cover.colorFilter,
contentDescription = null, contentDescription = null,
) )
} }
} }
} }
AnimatedOffset( Row(modifier = Modifier.padding(vertical = MaterialTheme.bibLib.dimen.medium)) {
modifier = Modifier AnimatedOffset(
.padding(16.dp) modifier = Modifier.weight(1f),
.align(Alignment.CenterHorizontally),
) {
Button(
onClick = onSendClick,
) { ) {
Icon(imageVector = Icons.Default.Send, contentDescription = "") Button(
Spacer(modifier = Modifier.width(4.dp)) modifier = Modifier.fillMaxSize(),
Text(text = stringResource(id = R.string.action_send)) onClick = onMobi,
) {
Icon(imageVector = Icons.Default.Download, contentDescription = null)
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.extraSmall))
Text(text = stringResource(id = R.string.action_mobi))
}
}
Spacer(modifier = Modifier.padding(all = MaterialTheme.bibLib.dimen.small))
AnimatedOffset(
modifier = Modifier.weight(1f),
) {
Button(
modifier = Modifier.fillMaxSize(),
onClick = onEpub,
) {
Icon(imageVector = Icons.Default.Download, contentDescription = null)
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.extraSmall))
Text(text = stringResource(id = R.string.action_epub))
}
}
Spacer(modifier = Modifier.padding(all = MaterialTheme.bibLib.dimen.small))
AnimatedOffset(
modifier = Modifier.weight(1f),
) {
Button(
modifier = Modifier.fillMaxSize(),
onClick = onSend,
) {
Icon(imageVector = Icons.Default.Send, contentDescription = "")
Spacer(modifier = Modifier.width(MaterialTheme.bibLib.dimen.extraSmall))
Text(text = stringResource(id = R.string.action_send))
}
} }
} }
@ -100,6 +181,7 @@ private fun DetailScreenContent(
) { ) {
Text( Text(
style = MaterialTheme.typography.h5, style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.onSurface,
text = book.title, text = book.title,
) )
} }
@ -112,6 +194,7 @@ private fun DetailScreenContent(
Text( Text(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
text = book.author, text = book.author,
) )
} }
@ -151,13 +234,20 @@ private fun DetailScreenContent(
} }
AnimatedOffset { AnimatedOffset {
HtmlText( SpannedText(
html = book.description, modifier = Modifier.padding(bottom = 16.dp),
modifier = Modifier.padding(bottom = 16.dp) style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
text = HtmlCompat.fromHtml(
book.description,
HtmlCompat.FROM_HTML_MODE_COMPACT
).toSpannable(),
) )
} }
} }
} }
BackHandler(onBack = onClose)
} }
@Composable @Composable
@ -172,35 +262,50 @@ private fun TitleLabel(
Text( Text(
style = MaterialTheme.typography.body2, style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
text = title, text = title,
) )
Text( Text(
style = MaterialTheme.typography.body1, style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface,
text = label, text = label,
) )
} }
} }
@Composable
private fun HtmlText(
html: String,
modifier: Modifier = Modifier
) {
AndroidView(
modifier = modifier,
factory = { context -> TextView(context) },
update = { it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) }
)
}
@Composable @Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun DetailScreenContentPreview() { private fun DetailScreenContentPreview() {
val painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24)
val cover = remember {
mutableStateOf(
CoverUio(
type = CoverUio.Type.PLACE_HOLDER,
contentScale = ContentScale.FillBounds,
painter = painter,
)
)
}
val book = BookUio(
id = 90,
title = "Foundation",
author = "Asimov",
date = "1951",
series = "Foundation - 1",
description = "En ce début de treizième millénaire, l'Empire n'a jamais été aussi puissant, aussi étendu à travers toute la galaxie. C'est dans sa capitale, Trantor, que l'éminent savant Hari Seldon invente la psychohistoire, une science nouvelle permettant de prédire l'avenir. Grâce à elle, Seldon prévoit l'effondrement de l'Empire d'ici cinq siècles, suivi d'une ère de ténèbres de trente mille ans. Réduire cette période à mille ans est peut-être possible, à condition de mener à terme son projet : la Fondation, chargée de rassembler toutes les connaissances humaines. Une entreprise visionnaire qui rencontre de nombreux et puissants détracteurs...",
rating = 4.5f,
language = "Français",
cover = cover,
)
BibLibTheme { BibLibTheme {
// DetailScreenContent( DetailScreenContent(
// book = book,
// ) onClose = {},
onMobi = {},
onEpub = {},
onSend = {},
)
} }
} }

View file

@ -11,7 +11,6 @@ import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -101,6 +100,7 @@ private fun Cover(
modifier = modifier, modifier = modifier,
alignment = Alignment.Center, alignment = Alignment.Center,
contentScale = cover.contentScale, contentScale = cover.contentScale,
colorFilter = cover.colorFilter,
painter = cover.painter, painter = cover.painter,
contentDescription = null, contentDescription = null,
) )
@ -122,8 +122,9 @@ private fun BookThumbnailPreview() {
cover = remember { cover = remember {
mutableStateOf( mutableStateOf(
CoverUio( CoverUio(
painter = painter, type = CoverUio.Type.PLACE_HOLDER,
contentScale = ContentScale.None, contentScale = ContentScale.None,
painter = painter,
) )
) )
}, },

View file

@ -63,6 +63,7 @@ private fun LazyBookThumbnailColumnPreview() {
@Composable @Composable
private fun previewResources(): LazyPagingItems<BookThumbnailUio> { private fun previewResources(): LazyPagingItems<BookThumbnailUio> {
val cover = CoverUio( val cover = CoverUio(
type = CoverUio.Type.PLACE_HOLDER,
painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24), painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24),
contentScale = ContentScale.None, contentScale = ContentScale.None,
) )

View file

@ -1,5 +1,6 @@
package com.pixelized.biblib.ui.screen.home.common.uio package com.pixelized.biblib.ui.screen.home.common.uio
import androidx.compose.runtime.State
import com.pixelized.biblib.network.client.IBibLibClient.Companion.COVER_URL import com.pixelized.biblib.network.client.IBibLibClient.Companion.COVER_URL
import java.net.URL import java.net.URL
@ -12,6 +13,5 @@ data class BookUio(
val date: String?, val date: String?,
val series: String?, val series: String?,
val description: String, val description: String,
) { val cover: State<CoverUio>,
val cover: URL = URL("${COVER_URL}/$id.jpg") )
}

View file

@ -2,12 +2,21 @@ package com.pixelized.biblib.ui.screen.home.common.uio
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@Stable @Stable
@Immutable @Immutable
data class CoverUio( data class CoverUio(
val type: Type,
val contentScale: ContentScale = ContentScale.FillBounds, val contentScale: ContentScale = ContentScale.FillBounds,
val colorFilter: ColorFilter? = null,
val painter: Painter, val painter: Painter,
) ) {
enum class Type {
PLACE_HOLDER,
THUMBNAIL,
DETAIL,
}
}

View file

@ -1,30 +1,35 @@
package com.pixelized.biblib.ui.screen.home.common.viewModel package com.pixelized.biblib.ui.screen.home.common.viewModel
import android.app.Application import android.app.Application
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.pixelized.biblib.R import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import com.pixelized.biblib.utils.BitmapCache import com.pixelized.biblib.utils.CoverCache
import com.pixelized.biblib.utils.extention.context
import com.pixelized.biblib.utils.painterResource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import java.net.URL import java.net.URL
abstract class ACoverViewModel( abstract class ACoverViewModel(
application: Application, application: Application,
private val cache: BitmapCache, private val cache: CoverCache,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
fun cover( fun cover(
cache: BitmapCache = this.cache, cache: CoverCache = this.cache,
coroutineScope: CoroutineScope = viewModelScope, coroutineScope: CoroutineScope = viewModelScope,
placeHolder: Painter = painterResource(context, R.drawable.ic_baseline_auto_stories_24), placeHolder: CoverUio,
type: CoverUio.Type,
contentScale: ContentScale = ContentScale.FillBounds,
tint: ColorFilter? = null,
url: URL, url: URL,
) = cache.download( ) = cache.cover(
placeHolder = placeHolder, placeHolder = placeHolder,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
type = type,
contentScale = contentScale,
tint = tint,
url = url, url = url,
) )

View file

@ -2,16 +2,21 @@ package com.pixelized.biblib.ui.screen.home.page.books
import android.app.Application import android.app.Application
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.layout.ContentScale
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.pixelized.biblib.R
import com.pixelized.biblib.model.book.Book import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.network.client.IBibLibClient import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.repository.book.IBookRepository import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.ui.screen.home.common.uio.BookThumbnailUio import com.pixelized.biblib.ui.screen.home.common.uio.BookThumbnailUio
import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import com.pixelized.biblib.ui.screen.home.common.viewModel.ACoverViewModel import com.pixelized.biblib.ui.screen.home.common.viewModel.ACoverViewModel
import com.pixelized.biblib.utils.BitmapCache import com.pixelized.biblib.utils.CoverCache
import com.pixelized.biblib.utils.extention.context
import com.pixelized.biblib.utils.extention.longDate import com.pixelized.biblib.utils.extention.longDate
import com.pixelized.biblib.utils.painterResource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.net.URL import java.net.URL
@ -22,7 +27,7 @@ import javax.inject.Inject
class BooksViewModel @Inject constructor( class BooksViewModel @Inject constructor(
application: Application, application: Application,
bookRepository: IBookRepository, bookRepository: IBookRepository,
cache: BitmapCache, cache: CoverCache,
) : ACoverViewModel(application, cache) { ) : ACoverViewModel(application, cache) {
private val booksSource = Pager( private val booksSource = Pager(
@ -41,6 +46,14 @@ class BooksViewModel @Inject constructor(
author = author.joinToString { it.name }, author = author.joinToString { it.name },
date = releaseDate.longDate(), date = releaseDate.longDate(),
isNew = isNew, isNew = isNew,
cover = cover(url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg")) cover = cover(
placeHolder = CoverUio(
type = CoverUio.Type.PLACE_HOLDER,
contentScale = ContentScale.None,
painter = painterResource(context, R.drawable.ic_baseline_auto_stories_24),
),
type = CoverUio.Type.THUMBNAIL,
url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg"),
)
) )
} }

View file

@ -2,17 +2,22 @@ package com.pixelized.biblib.ui.screen.home.page.news
import android.app.Application import android.app.Application
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.layout.ContentScale
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.pixelized.biblib.R
import com.pixelized.biblib.model.book.Book import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.network.client.IBibLibClient import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.repository.book.IBookRepository import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.ui.screen.home.common.uio.BookThumbnailUio import com.pixelized.biblib.ui.screen.home.common.uio.BookThumbnailUio
import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import com.pixelized.biblib.ui.screen.home.common.viewModel.ACoverViewModel import com.pixelized.biblib.ui.screen.home.common.viewModel.ACoverViewModel
import com.pixelized.biblib.utils.BitmapCache import com.pixelized.biblib.utils.CoverCache
import com.pixelized.biblib.utils.extention.context
import com.pixelized.biblib.utils.extention.longDate import com.pixelized.biblib.utils.extention.longDate
import com.pixelized.biblib.utils.painterResource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -24,7 +29,7 @@ import javax.inject.Inject
class NewsBookViewModel @Inject constructor( class NewsBookViewModel @Inject constructor(
application: Application, application: Application,
bookRepository: IBookRepository, bookRepository: IBookRepository,
cache: BitmapCache, cache: CoverCache,
) : ACoverViewModel(application, cache) { ) : ACoverViewModel(application, cache) {
private val newsSource: Flow<PagingData<BookThumbnailUio>> = Pager( private val newsSource: Flow<PagingData<BookThumbnailUio>> = Pager(
@ -43,6 +48,14 @@ class NewsBookViewModel @Inject constructor(
author = author.joinToString { it.name }, author = author.joinToString { it.name },
date = releaseDate.longDate(), date = releaseDate.longDate(),
isNew = isNew, isNew = isNew,
cover = cover(url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg")) cover = cover(
placeHolder = CoverUio(
type = CoverUio.Type.PLACE_HOLDER,
contentScale = ContentScale.None,
painter = painterResource(context, R.drawable.ic_baseline_auto_stories_24),
),
type = CoverUio.Type.THUMBNAIL,
url = URL("${IBibLibClient.THUMBNAIL_URL}/$id.jpg"),
)
) )
} }

View file

@ -17,8 +17,8 @@ data class BibLibDimen(
val extraLarge: Dp = 64.dp, val extraLarge: Dp = 64.dp,
val dialog: Dialog = Dialog(), val dialog: Dialog = Dialog(),
val thumbnail: BookThumbnail = BookThumbnail(), val thumbnail: BookThumbnail = BookThumbnail(),
val detail: BookDetail = BookDetail(),
) { ) {
@Stable @Stable
@Immutable @Immutable
data class Dialog( data class Dialog(
@ -32,7 +32,14 @@ data class BibLibDimen(
data class BookThumbnail( data class BookThumbnail(
val padding: Dp = 16.dp, val padding: Dp = 16.dp,
val arrangement: Dp = 8.dp, val arrangement: Dp = 8.dp,
val cover: DpSize = DpSize(60.dp, 96.dp), val cover: DpSize = DpSize(64.dp, 102.dp), // ratio 1.6
val corner: Dp = 8.dp, val corner: Dp = 8.dp,
) )
@Stable
@Immutable
data class BookDetail(
val placeHolder: Dp = 64.dp,
val cover: Dp = 384.dp,
)
} }

View file

@ -1,97 +0,0 @@
package com.pixelized.biblib.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
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 com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.net.URL
import javax.inject.Inject
class BitmapCache @Inject constructor(context: Context) {
private var cache: File? = context.cacheDir
fun writeToDisk(url: URL, bitmap: Bitmap) {
val file = file(url)
try {
file.mkdirs()
file.delete()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
} catch (e: Exception) {
Log.e("BitmapCache", "bitmap?.compress() FAILED !", e)
}
}
fun readFromDisk(url: URL): Bitmap? {
val file = file(url)
return try {
BitmapFactory.decodeStream(file.inputStream())
} catch (e: IOException) {
null
}
}
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) {
Log.e("BitmapCache", "BitmapFactory.decodeStream(URL) FAILED !", e)
null
}
}
}
fun download(
coroutineScope: CoroutineScope,
placeHolder: Painter,
url: URL,
): State<CoverUio> {
if (exist(url)) {
val bitmap = 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 = readFromDisk(url)?.let { BitmapPainter(it.asImageBitmap()) }
if (resource != null) {
state.value = CoverUio(painter = resource)
} else {
val downloaded = download(url)
if (downloaded != null) {
writeToDisk(url, downloaded)
state.value = CoverUio(painter = BitmapPainter(downloaded.asImageBitmap()))
}
}
}
return state
}
}
private fun file(url: URL): File = File(cache?.absolutePath + url.file)
}

View file

@ -0,0 +1,101 @@
package com.pixelized.biblib.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import java.net.URL
import javax.inject.Inject
class CoverCache @Inject constructor(context: Context) {
private var cache: File? = context.cacheDir
fun cover(
coroutineScope: CoroutineScope,
placeHolder: CoverUio,
url: URL,
type: CoverUio.Type,
contentScale: ContentScale,
tint: ColorFilter?,
): State<CoverUio> {
// read the cache a convert it to a UIO.
val cache = readFromDisk(url)?.let {
CoverUio(
type = type,
contentScale = contentScale,
colorFilter = tint,
painter = BitmapPainter(it.asImageBitmap()),
)
}
// publish the cache and stop there, or publish the placeHolder and download the proper file.
return mutableStateOf(cache ?: placeHolder).also {
if (cache == null) {
coroutineScope.launch(Dispatchers.IO) {
it.download(
type = type,
contentScale = contentScale,
tint = tint,
url = url,
)
}
}
}
}
private fun MutableState<CoverUio>.download(
type: CoverUio.Type,
contentScale: ContentScale,
tint: ColorFilter?,
url: URL,
) {
try {
val bitmap = BitmapFactory.decodeStream(url.openStream())
val painter = BitmapPainter(bitmap.asImageBitmap())
writeToDisk(url, bitmap)
value = CoverUio(
type = type,
contentScale = contentScale,
colorFilter = tint,
painter = painter,
)
} catch (exception: Exception) {
Log.w("CoverCache", "Fail to download: {$url}", exception)
}
}
private fun file(url: URL): File = File(cache?.absolutePath + url.file)
private fun writeToDisk(url: URL, bitmap: Bitmap) {
val file = file(url)
try {
file.mkdirs()
file.delete()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
} catch (e: Exception) {
Log.e("BitmapCache", "bitmap?.compress() FAILED !", e)
}
}
private fun readFromDisk(url: URL): Bitmap? {
val file = file(url)
return try {
if (file.exists()) BitmapFactory.decodeStream(file.inputStream()) else null
} catch (e: IOException) {
null
}
}
}