diff --git a/app/src/main/java/com/pixelized/biblib/module/PersistenceModule.kt b/app/src/main/java/com/pixelized/biblib/module/PersistenceModule.kt index 102bbad..0885280 100644 --- a/app/src/main/java/com/pixelized/biblib/module/PersistenceModule.kt +++ b/app/src/main/java/com/pixelized/biblib/module/PersistenceModule.kt @@ -1,11 +1,10 @@ package com.pixelized.biblib.module -import android.app.Application import android.content.Context import android.content.SharedPreferences import androidx.room.Room import com.pixelized.biblib.database.BibLibDatabase -import com.pixelized.biblib.utils.BitmapCache +import com.pixelized.biblib.utils.CoverCache import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,8 +33,8 @@ class PersistenceModule { @Singleton fun provideBitmapCache( @ApplicationContext context: Context, - ): BitmapCache { - return BitmapCache(context) + ): CoverCache { + return CoverCache(context) } @Provides diff --git a/app/src/main/java/com/pixelized/biblib/ui/composable/Text.kt b/app/src/main/java/com/pixelized/biblib/ui/composable/Text.kt new file mode 100644 index 0000000..d03f47e --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/ui/composable/Text.kt @@ -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 = 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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/composable/animation/AnimatedOffset.kt b/app/src/main/java/com/pixelized/biblib/ui/composable/animation/AnimatedOffset.kt index 3207ffc..f938ee2 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/composable/animation/AnimatedOffset.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/composable/animation/AnimatedOffset.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInspectionMode @@ -22,12 +23,14 @@ import androidx.compose.ui.unit.dp fun AnimatedDelayerScope.AnimatedOffset( modifier: Modifier = Modifier, transitionLabel: String = "AnimatedOffset", + contentAlignment: Alignment = Alignment.TopStart, content: @Composable BoxScope.() -> Unit, ) { AnimatedOffset( modifier = modifier, transitionLabel = transitionLabel, delay = delay++, + contentAlignment = contentAlignment, content = content, ) } @@ -37,6 +40,7 @@ fun AnimatedOffset( modifier: Modifier = Modifier, transitionLabel: String = "AnimatedOffset", delay: Delay = Delay(), + contentAlignment: Alignment = Alignment.TopStart, content: @Composable BoxScope.() -> Unit, ) { val displayed = rememberSavableMutableTransitionState( @@ -48,6 +52,7 @@ fun AnimatedOffset( transitionLabel = transitionLabel, displayed = displayed, delay = delay, + contentAlignment = contentAlignment, content = content, ) } @@ -58,6 +63,7 @@ fun AnimatedOffset( displayed: MutableTransitionState, transitionLabel: String = "AnimatedOffset", delay: Delay = Delay(), + contentAlignment: Alignment = Alignment.TopStart, content: @Composable BoxScope.() -> Unit, ) { val transition: TransitionData = updateTransition( @@ -69,6 +75,7 @@ fun AnimatedOffset( modifier = modifier .offset(y = transition.offset) .graphicsLayer(alpha = transition.alpha, clip = false), + contentAlignment = contentAlignment, content = content ) } diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/detail/BookDetailViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/detail/BookDetailViewModel.kt index 3858378..a2d6fae 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/detail/BookDetailViewModel.kt @@ -1,30 +1,41 @@ package com.pixelized.biblib.ui.screen.detail +import android.app.Application import android.util.Log import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.layout.ContentScale import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.ui.composable.StateUio 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.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.context import com.pixelized.biblib.utils.extention.shortDate +import com.pixelized.biblib.utils.painterResource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.net.URL import javax.inject.Inject @HiltViewModel class BookDetailViewModel @Inject constructor( + application: Application, + coverCache: CoverCache, savedStateHandle: SavedStateHandle, private val client: IBibLibClient, -) : ViewModel() { +) : ACoverViewModel(application, coverCache) { private val _state = mutableStateOf>(StateUio.Progress()) val state: State> get() = _state @@ -53,16 +64,34 @@ class BookDetailViewModel @Inject constructor( return book.toUio() } - private fun Book.toUio() = 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 ?: "", - ) + private fun Book.toUio() : BookUio { + val thumbnailCover by cover( + placeHolder = CoverUio( + type = CoverUio.Type.PLACE_HOLDER, + contentScale = ContentScale.FillBounds, + painter = painterResource(context, R.drawable.ic_baseline_auto_stories_24), + ), + type = CoverUio.Type.THUMBNAIL, + 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 get() = get(Screen.BookDetail.ARG_BOOK_ID) ?: error("") diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/detail/DetailScreen.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/detail/DetailScreen.kt index 16ecb7a..1356705 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/detail/DetailScreen.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/detail/DetailScreen.kt @@ -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_YES -import android.widget.TextView +import androidx.activity.compose.BackHandler +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.* 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.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.text.HtmlCompat +import androidx.core.text.toSpannable import androidx.hilt.navigation.compose.hiltViewModel -import com.google.accompanist.insets.systemBarsPadding 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.AnimatedOffset 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.CoverUio import com.pixelized.biblib.ui.theme.BibLibTheme +import com.pixelized.biblib.utils.extention.bibLib @Composable fun DetailScreen( viewModel: BookDetailViewModel = hiltViewModel() ) { + val screenNavHostController = LocalScreenNavHostController.current Box { val book by viewModel.book book?.let { DetailScreenContent( book = it, - onSendClick = {}, + onClose = { + screenNavHostController.popBackStack() + }, + onMobi = {}, + onEpub = {}, + onSend = {}, ) } } } +@OptIn(ExperimentalAnimationApi::class) @Composable private fun DetailScreenContent( + modifier: Modifier = Modifier, book: BookUio, - onSendClick: () -> Unit, + onClose: () -> Unit, + onMobi: () -> Unit, + onEpub: () -> Unit, + onSend: () -> Unit, ) { AnimatedDelayer(delay = Delay(300)) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) .systemBarsPadding() - .padding(horizontal = 16.dp) + .padding(horizontal = MaterialTheme.bibLib.dimen.medium) + .then(modifier) ) { AnimatedOffset( - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterHorizontally), + modifier = Modifier.align(Alignment.End), ) { - Box( - modifier = Modifier - .size(200.dp, 320.dp) - .background(MaterialTheme.colors.surface), - ) { - Box(contentAlignment = Alignment.Center) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + tint = MaterialTheme.colors.onSurface, + contentDescription = null + ) + } + } + + 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( - modifier = Modifier.size(64.dp), - painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24), + modifier = Modifier.size(MaterialTheme.bibLib.dimen.detail.placeHolder), + 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, ) } } } - AnimatedOffset( - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterHorizontally), - ) { - Button( - onClick = onSendClick, + Row(modifier = Modifier.padding(vertical = MaterialTheme.bibLib.dimen.medium)) { + AnimatedOffset( + modifier = Modifier.weight(1f), ) { - Icon(imageVector = Icons.Default.Send, contentDescription = "") - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(id = R.string.action_send)) + Button( + modifier = Modifier.fillMaxSize(), + 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( style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onSurface, text = book.title, ) } @@ -112,6 +194,7 @@ private fun DetailScreenContent( Text( fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onSurface, text = book.author, ) } @@ -151,13 +234,20 @@ private fun DetailScreenContent( } AnimatedOffset { - HtmlText( - html = book.description, - modifier = Modifier.padding(bottom = 16.dp) + SpannedText( + 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 @@ -172,35 +262,50 @@ private fun TitleLabel( Text( style = MaterialTheme.typography.body2, fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, text = title, ) Text( style = MaterialTheme.typography.body1, textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface, 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 @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) 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 { -// DetailScreenContent( -// -// ) + DetailScreenContent( + book = book, + onClose = {}, + onMobi = {}, + onEpub = {}, + onSend = {}, + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/BookThumbnail.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/BookThumbnail.kt index 035fac6..a43e0ec 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/BookThumbnail.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/BookThumbnail.kt @@ -11,7 +11,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow @@ -101,6 +100,7 @@ private fun Cover( modifier = modifier, alignment = Alignment.Center, contentScale = cover.contentScale, + colorFilter = cover.colorFilter, painter = cover.painter, contentDescription = null, ) @@ -122,8 +122,9 @@ private fun BookThumbnailPreview() { cover = remember { mutableStateOf( CoverUio( - painter = painter, + type = CoverUio.Type.PLACE_HOLDER, contentScale = ContentScale.None, + painter = painter, ) ) }, diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/LazyBookThumbnailColumn.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/LazyBookThumbnailColumn.kt index 302744b..fb48f4d 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/LazyBookThumbnailColumn.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/composable/LazyBookThumbnailColumn.kt @@ -63,6 +63,7 @@ private fun LazyBookThumbnailColumnPreview() { @Composable private fun previewResources(): LazyPagingItems { val cover = CoverUio( + type = CoverUio.Type.PLACE_HOLDER, painter = painterResource(id = R.drawable.ic_baseline_auto_stories_24), contentScale = ContentScale.None, ) diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/BookUio.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/BookUio.kt index 5329fee..622b098 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/BookUio.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/BookUio.kt @@ -1,5 +1,6 @@ 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 java.net.URL @@ -12,6 +13,5 @@ data class BookUio( val date: String?, val series: String?, val description: String, -) { - val cover: URL = URL("${COVER_URL}/$id.jpg") -} + val cover: State, +) diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/CoverUio.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/CoverUio.kt index c07d562..26dac43 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/CoverUio.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/uio/CoverUio.kt @@ -2,12 +2,21 @@ package com.pixelized.biblib.ui.screen.home.common.uio import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale @Stable @Immutable data class CoverUio( + val type: Type, val contentScale: ContentScale = ContentScale.FillBounds, + val colorFilter: ColorFilter? = null, val painter: Painter, -) \ No newline at end of file +) { + enum class Type { + PLACE_HOLDER, + THUMBNAIL, + DETAIL, + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/viewModel/ACoverViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/viewModel/ACoverViewModel.kt index 59cf1a4..32db58a 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/viewModel/ACoverViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/common/viewModel/ACoverViewModel.kt @@ -1,30 +1,35 @@ package com.pixelized.biblib.ui.screen.home.common.viewModel 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.viewModelScope -import com.pixelized.biblib.R -import com.pixelized.biblib.utils.BitmapCache -import com.pixelized.biblib.utils.extention.context -import com.pixelized.biblib.utils.painterResource +import com.pixelized.biblib.ui.screen.home.common.uio.CoverUio +import com.pixelized.biblib.utils.CoverCache import kotlinx.coroutines.CoroutineScope import java.net.URL abstract class ACoverViewModel( application: Application, - private val cache: BitmapCache, + private val cache: CoverCache, ) : AndroidViewModel(application) { fun cover( - cache: BitmapCache = this.cache, + cache: CoverCache = this.cache, 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, - ) = cache.download( + ) = cache.cover( placeHolder = placeHolder, coroutineScope = coroutineScope, + type = type, + contentScale = contentScale, + tint = tint, url = url, ) diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/books/BooksViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/books/BooksViewModel.kt index 1cb9c02..8352f09 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/books/BooksViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/books/BooksViewModel.kt @@ -2,16 +2,21 @@ package com.pixelized.biblib.ui.screen.home.page.books import android.app.Application import androidx.compose.runtime.Composable +import androidx.compose.ui.layout.ContentScale import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.compose.collectAsLazyPagingItems +import com.pixelized.biblib.R import com.pixelized.biblib.model.book.Book import com.pixelized.biblib.network.client.IBibLibClient 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.CoverUio 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.painterResource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import java.net.URL @@ -22,7 +27,7 @@ import javax.inject.Inject class BooksViewModel @Inject constructor( application: Application, bookRepository: IBookRepository, - cache: BitmapCache, + cache: CoverCache, ) : ACoverViewModel(application, cache) { private val booksSource = Pager( @@ -41,6 +46,14 @@ class BooksViewModel @Inject constructor( author = author.joinToString { it.name }, date = releaseDate.longDate(), 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"), + ) ) } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/news/NewsBookViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/news/NewsBookViewModel.kt index f992059..f9a363d 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/news/NewsBookViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/page/news/NewsBookViewModel.kt @@ -2,17 +2,22 @@ package com.pixelized.biblib.ui.screen.home.page.news import android.app.Application import androidx.compose.runtime.Composable +import androidx.compose.ui.layout.ContentScale import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems +import com.pixelized.biblib.R import com.pixelized.biblib.model.book.Book import com.pixelized.biblib.network.client.IBibLibClient 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.CoverUio 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.painterResource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -24,7 +29,7 @@ import javax.inject.Inject class NewsBookViewModel @Inject constructor( application: Application, bookRepository: IBookRepository, - cache: BitmapCache, + cache: CoverCache, ) : ACoverViewModel(application, cache) { private val newsSource: Flow> = Pager( @@ -43,6 +48,14 @@ class NewsBookViewModel @Inject constructor( author = author.joinToString { it.name }, date = releaseDate.longDate(), 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"), + ) ) } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/theme/dimen/BibLibDimen.kt b/app/src/main/java/com/pixelized/biblib/ui/theme/dimen/BibLibDimen.kt index f74de04..74d33b2 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/theme/dimen/BibLibDimen.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/theme/dimen/BibLibDimen.kt @@ -17,8 +17,8 @@ data class BibLibDimen( val extraLarge: Dp = 64.dp, val dialog: Dialog = Dialog(), val thumbnail: BookThumbnail = BookThumbnail(), - - ) { + val detail: BookDetail = BookDetail(), +) { @Stable @Immutable data class Dialog( @@ -32,7 +32,14 @@ data class BibLibDimen( data class BookThumbnail( val padding: Dp = 16.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, ) + + @Stable + @Immutable + data class BookDetail( + val placeHolder: Dp = 64.dp, + val cover: Dp = 384.dp, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/utils/BitmapCache.kt b/app/src/main/java/com/pixelized/biblib/utils/BitmapCache.kt deleted file mode 100644 index 47f5a11..0000000 --- a/app/src/main/java/com/pixelized/biblib/utils/BitmapCache.kt +++ /dev/null @@ -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 { - 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) -} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/utils/CoverCache.kt b/app/src/main/java/com/pixelized/biblib/utils/CoverCache.kt new file mode 100644 index 0000000..f5e5ffa --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/utils/CoverCache.kt @@ -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 { + // 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.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 + } + } +} \ No newline at end of file