[wip] chat materials; some experiments with local composition and blur

This commit is contained in:
2024-07-12 00:51:24 +03:00
parent c43278e4cf
commit fb76b46b22
46 changed files with 1210 additions and 717 deletions
@@ -1,91 +0,0 @@
package com.meloda.app.fast.chatmaterials
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit
) {
val hazeState = remember { HazeState() }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Chat Materials")
},
colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent),
modifier = Modifier
.hazeChild(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.fillMaxWidth(),
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(200.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.haze(
state = hazeState,
style = HazeMaterials.ultraThin()
)
) {
items(100) { index ->
val link = "https://random.imagecdn.app/500/150"
AsyncImage(
model = link,
contentDescription = "Image",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
}
}
}
}
@@ -0,0 +1,130 @@
package com.meloda.app.fast.chatmaterials
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.chatmaterials.model.ChatMaterialsScreenState
import com.meloda.app.fast.chatmaterials.navigation.ChatMaterials
import com.meloda.app.fast.chatmaterials.util.asPresentation
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface ChatMaterialsViewModel {
val screenState: StateFlow<ChatMaterialsScreenState>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onRefresh()
fun onErrorConsumed()
fun onTypeChanged(newType: String)
}
class ChatMaterialsViewModelImpl(
private val messagesUseCase: MessagesUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel(), ChatMaterialsViewModel {
override val screenState = MutableStateFlow(ChatMaterialsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
init {
val arguments = ChatMaterials.from(savedStateHandle)
screenState.setValue { old ->
old.copy(
peerId = arguments.peerId,
conversationMessageId = arguments.conversationMessageId
)
}
loadChatMaterials()
}
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.materials.size }
loadChatMaterials()
}
override fun onRefresh() {
loadChatMaterials(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
override fun onTypeChanged(newType: String) {
screenState.setValue { old -> old.copy(attachmentType = newType) }
loadChatMaterials(0)
}
private fun loadChatMaterials(
offset: Int = currentOffset.value
) {
messagesUseCase.getHistoryAttachments(
peerId = screenState.value.peerId,
count = LOAD_COUNT,
offset = offset,
attachmentTypes = listOf(screenState.value.attachmentType),
conversationMessageId = screenState.value.conversationMessageId
).listenValue { state ->
state.processState(
error = { error ->
},
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.materials.size >= LOAD_COUNT
val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation)
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
screenState.setValue {
newState.copy(materials = loadedMaterials)
}
} else {
screenState.setValue {
newState.copy(
materials = newState.materials.plus(loadedMaterials)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
companion object {
const val LOAD_COUNT = 100
}
}
@@ -0,0 +1,9 @@
package com.meloda.app.fast.chatmaterials.di
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.dsl.module
val chatMaterialsModule = module {
viewModelOf(::ChatMaterialsViewModelImpl)
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.chatmaterials.model
data class ChatMaterialsScreenState(
val isLoading: Boolean,
val materials: List<UiChatMaterial>,
val attachmentType: String,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val peerId: Int,
val conversationMessageId: Int
) {
companion object {
val EMPTY: ChatMaterialsScreenState = ChatMaterialsScreenState(
isLoading = true,
materials = emptyList(),
attachmentType = "photo",
isPaginating = false,
isPaginationExhausted = false,
peerId = -1,
conversationMessageId = -1
)
}
}
@@ -0,0 +1,28 @@
package com.meloda.app.fast.chatmaterials.model
sealed class UiChatMaterial {
data class Photo(
val previewUrl: String
) : UiChatMaterial()
data class Video(
val previewUrl: String
) : UiChatMaterial()
data class Audio(
val previewUrl: String?,
val title: String,
val artist: String,
val duration: String
) : UiChatMaterial()
data class File(
val title: String
) : UiChatMaterial()
data class Link(
val title: String,
val previewUrl: String?
) : UiChatMaterial()
}
@@ -1,13 +1,23 @@
package com.meloda.app.fast.chatmaterials.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.chatmaterials.ChatMaterialsScreen
import androidx.navigation.toRoute
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsScreen
import kotlinx.serialization.Serializable
@Serializable
data class ChatMaterials(val a: String)
data class ChatMaterials(
val peerId: Int,
val conversationMessageId: Int
) {
companion object {
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<ChatMaterials>()
}
}
fun NavGraphBuilder.chatMaterialsRoute(
onBack: () -> Unit
@@ -19,6 +29,11 @@ fun NavGraphBuilder.chatMaterialsRoute(
}
}
fun NavController.navigateToChatMaterials() {
this.navigate(ChatMaterials(""))
fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) {
this.navigate(
ChatMaterials(
peerId = peerId,
conversationMessageId = conversationMessageId
)
)
}
@@ -0,0 +1,68 @@
package com.meloda.app.fast.chatmaterials.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil.ImageLoader
import coil.compose.AsyncImage
import com.meloda.app.fast.chatmaterials.model.UiChatMaterial
@Composable
fun ChatMaterialItem(
item: UiChatMaterial,
imageLoader: ImageLoader
) {
when (item) {
is UiChatMaterial.Photo -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
imageLoader = imageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
is UiChatMaterial.Video -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
imageLoader = imageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
is UiChatMaterial.Audio -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge
)
Text(text = item.artist)
}
Text(text = item.duration)
}
}
is UiChatMaterial.File -> {}
is UiChatMaterial.Link -> {}
}
}
@@ -0,0 +1,352 @@
package com.meloda.app.fast.chatmaterials.presentation
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModel
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.R
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val attachments = screenState.materials
val imageLoader = LocalContext.current.imageLoader
var moreClearBlur by rememberSaveable {
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val hazeStyle = if (moreClearBlur) HazeMaterials.ultraThin() else HazeMaterials.regular()
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
var checkedTypeIndex by rememberSaveable {
mutableIntStateOf(0)
}
LaunchedEffect(checkedTypeIndex) {
viewModel.onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
2 -> "audio"
3 -> "doc"
4 -> "link"
else -> ""
}
)
}
val titles = listOf("Photos", "Videos", "Audios", "Files", "Links")
val listState = rememberLazyListState()
val gridState = rememberLazyGridState()
val canScrollBackward = when (checkedTypeIndex) {
in 0..1 -> gridState.canScrollBackward
else -> listState.canScrollBackward
}
Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward")
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshAlpha by animateFloatAsState(
targetValue = if (!canScrollBackward) 1f else 0f,
label = "pullToRefreshAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshState = rememberPullToRefreshState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Chat Materials")
},
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = hazeStyle
)
} else Modifier
)
.fillMaxWidth(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
)
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
}
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options button"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
if (currentTheme.usingBlur) {
DropdownMenuItem(
text = {
Text(text = if (moreClearBlur) "Default blur" else "Clearer blur")
},
onClick = {
moreClearBlur = !moreClearBlur
dropDownMenuExpanded = false
}
)
}
HorizontalDivider()
titles.forEachIndexed { index, title ->
DropdownMenuItem(
leadingIcon = {
RadioButton(
selected = checkedTypeIndex == index,
onClick = null
)
},
text = {
Text(text = title)
},
onClick = {
checkedTypeIndex = index
dropDownMenuExpanded = false
}
)
}
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
if (checkedTypeIndex in listOf(0, 1)) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = hazeStyle
)
} else {
Modifier
}
)
.fillMaxSize()
) {
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
imageLoader = imageLoader
)
}
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = hazeStyle
)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
imageLoader = imageLoader
)
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.alpha(pullToRefreshAlpha)
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
}
@@ -0,0 +1,59 @@
package com.meloda.app.fast.chatmaterials.util
import com.meloda.app.fast.chatmaterials.model.UiChatMaterial
import com.meloda.app.fast.model.api.data.AttachmentType
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkAudioDomain
import com.meloda.app.fast.model.api.domain.VkFileDomain
import com.meloda.app.fast.model.api.domain.VkLinkDomain
import com.meloda.app.fast.model.api.domain.VkPhotoDomain
import com.meloda.app.fast.model.api.domain.VkVideoDomain
import java.text.SimpleDateFormat
import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
when (val type = this.attachment.type) {
AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo(
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
)
}
AttachmentType.VIDEO -> {
val attachment = this.attachment as VkVideoDomain
UiChatMaterial.Video(
previewUrl = attachment.images.firstOrNull()?.url.orEmpty()
)
}
AttachmentType.AUDIO -> {
val attachment = this.attachment as VkAudioDomain
UiChatMaterial.Audio(
previewUrl = null,
title = attachment.title,
artist = attachment.artist,
duration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(attachment.duration)
)
}
AttachmentType.FILE -> {
val attachment = this.attachment as VkFileDomain
UiChatMaterial.File(
title = attachment.title
)
}
AttachmentType.LINK -> {
val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link(
title = attachment.title ?: attachment.url,
previewUrl = attachment.photo?.getMaxSize()?.url
)
}
else -> throw IllegalArgumentException("Unsupported type: $type")
}