chat materials pagination and ui improvements

This commit is contained in:
2025-03-27 03:50:39 +03:00
parent 807c23926e
commit 51356aa4dd
13 changed files with 359 additions and 48 deletions
@@ -27,7 +27,7 @@ data class VkAudioData(
@Json(name = "title") val title: String, @Json(name = "title") val title: String,
@Json(name = "owner_id") val ownerId: Int, @Json(name = "owner_id") val ownerId: Int,
@Json(name = "access_key") val accessKey: String, @Json(name = "access_key") val accessKey: String,
@Json(name = "thumb") val thumb: Thumb @Json(name = "thumb") val thumb: Thumb?
) { ) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -1,22 +1,22 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkPhotoDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkPhotoData( data class VkPhotoData(
@Json(name = "album_id") val albumId: Int, @Json(name = "album_id") val albumId: Int,
val date: Int, @Json(name = "date") val date: Int?,
val id: Int, @Json(name = "id") val id: Int,
@Json(name = "owner_id") val ownerId: Int, @Json(name = "owner_id") val ownerId: Int,
@Json(name = "has_tags") val hasTags: Boolean, @Json(name = "has_tags") val hasTags: Boolean?,
@Json(name = "access_key") val accessKey: String?, @Json(name = "access_key") val accessKey: String?,
val sizes: List<Size>, @Json(name = "sizes") val sizes: List<Size>,
val text: String?, @Json(name = "text") val text: String?,
@Json(name = "user_id") val userId: Int?, @Json(name = "user_id") val userId: Int?,
val lat: Double?, @Json(name = "lat") val lat: Double?,
val long: Double?, @Json(name = "long") val long: Double?,
@Json(name = "post_id") val postId: Int? @Json(name = "post_id") val postId: Int?
) : VkAttachmentData { ) : VkAttachmentData {
@@ -33,7 +33,7 @@ data class VkPhotoData(
date = date, date = date,
id = id, id = id,
ownerId = ownerId, ownerId = ownerId,
hasTags = hasTags, hasTags = hasTags == true,
accessKey = accessKey, accessKey = accessKey,
sizes = sizes, sizes = sizes,
text = text, text = text,
@@ -8,7 +8,7 @@ import java.util.Stack
// TODO: 11/04/2024, Danil Nikolaev: review // TODO: 11/04/2024, Danil Nikolaev: review
data class VkPhotoDomain( data class VkPhotoDomain(
val albumId: Int, val albumId: Int,
val date: Int, val date: Int?,
val id: Int, val id: Int,
val ownerId: Int, val ownerId: Int,
val hasTags: Boolean, val hasTags: Boolean,
@@ -9,10 +9,12 @@ import dev.meloda.fast.chatmaterials.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -60,7 +62,7 @@ class ChatMaterialsViewModelImpl(
} }
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.materials.size } currentOffset.setValue { old -> old + LOAD_COUNT }
loadChatMaterials() loadChatMaterials()
} }
@@ -86,20 +88,24 @@ class ChatMaterialsViewModelImpl(
conversationMessageId = screenState.value.conversationMessageId conversationMessageId = screenState.value.conversationMessageId
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
},
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient
screenState.value.materials.size >= LOAD_COUNT && screenState.value.materials.isNotEmpty()
val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation) val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation)
val newState = screenState.value.copy( val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted isPaginationExhausted = paginationExhausted,
conversationMessageId = if (loadedMaterials.size + offset > 200) {
currentOffset.setValue { 0 }
loadedMaterials.lastOrNull()?.conversationMessageId ?: -1
} else {
screenState.value.conversationMessageId
}
) )
if (offset == 0) { if (offset == 0) {
@@ -125,7 +131,45 @@ class ChatMaterialsViewModelImpl(
} }
} }
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
companion object { companion object {
const val LOAD_COUNT = 30 const val LOAD_COUNT = 200
} }
} }
@@ -1,36 +1,43 @@
package dev.meloda.fast.chatmaterials.model package dev.meloda.fast.chatmaterials.model
sealed class UiChatMaterial { sealed class UiChatMaterial(
open val conversationMessageId: Int
) {
data class Photo( data class Photo(
override val conversationMessageId: Int,
val previewUrl: String val previewUrl: String
) : UiChatMaterial() ) : UiChatMaterial(conversationMessageId)
data class Video( data class Video(
override val conversationMessageId: Int,
val previewUrl: String?, val previewUrl: String?,
val title: String, val title: String,
val views: Int, val views: Int,
val duration: String val duration: String
) : UiChatMaterial() ) : UiChatMaterial(conversationMessageId)
data class Audio( data class Audio(
override val conversationMessageId: Int,
val previewUrl: String?, val previewUrl: String?,
val title: String, val title: String,
val artist: String, val artist: String,
val duration: String val duration: String
) : UiChatMaterial() ) : UiChatMaterial(conversationMessageId)
data class File( data class File(
override val conversationMessageId: Int,
val previewUrl: String?, val previewUrl: String?,
val title: String, val title: String,
val size: String, val size: String,
val extension: String val extension: String
) : UiChatMaterial() ) : UiChatMaterial(conversationMessageId)
data class Link( data class Link(
override val conversationMessageId: Int,
val previewUrl: String?, val previewUrl: String?,
val title: String?, val title: String?,
val url: String, val url: String,
val urlFirstChar: String val urlFirstChar: String
) : UiChatMaterial() ) : UiChatMaterial(conversationMessageId)
} }
@@ -17,9 +17,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -171,10 +173,17 @@ fun ChatMaterialsScreen(
} }
} }
) )
PrimaryTabRow( ScrollableTabRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent containerColor = Color.Transparent,
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = MaterialTheme.colorScheme.primary
)
}
) { ) {
tabItems.forEachIndexed { index, item -> tabItems.forEachIndexed { index, item ->
Tab( Tab(
@@ -19,7 +19,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -28,9 +34,11 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -60,6 +68,8 @@ import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -74,6 +84,7 @@ fun FileMaterialsScreen(
setCanScrollBackward: (Boolean) -> Unit, setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit onPaginationConditionsMet: () -> Unit
) { ) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -84,6 +95,20 @@ fun FileMaterialsScreen(
.collect(setCanScrollBackward) .collect(setCanScrollBackward)
} }
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when { when {
baseError != null -> { baseError != null -> {
when (baseError) { when (baseError) {
@@ -207,6 +232,37 @@ fun FileMaterialsScreen(
} }
} }
} }
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item { item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
} }
@@ -19,7 +19,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -28,9 +34,11 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -60,6 +68,8 @@ import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -74,6 +84,7 @@ fun LinkMaterialsScreen(
setCanScrollBackward: (Boolean) -> Unit, setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit onPaginationConditionsMet: () -> Unit
) { ) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -84,6 +95,20 @@ fun LinkMaterialsScreen(
.collect(setCanScrollBackward) .collect(setCanScrollBackward)
} }
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when { when {
baseError != null -> { baseError != null -> {
when (baseError) { when (baseError) {
@@ -226,6 +251,37 @@ fun LinkMaterialsScreen(
} }
} }
} }
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item { item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
} }
@@ -2,6 +2,7 @@ package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -12,15 +13,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -39,6 +51,8 @@ import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -54,6 +68,7 @@ fun PhotoMaterialsScreen(
onPhotoClicked: (String) -> Unit, onPhotoClicked: (String) -> Unit,
onPaginationConditionsMet: () -> Unit onPaginationConditionsMet: () -> Unit
) { ) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
@@ -64,6 +79,20 @@ fun PhotoMaterialsScreen(
.collect(setCanScrollBackward) .collect(setCanScrollBackward)
} }
val paginationConditionMet by remember(canPaginate, gridState) {
derivedStateOf {
canPaginate &&
(gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (gridState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when { when {
baseError != null -> { baseError != null -> {
when (baseError) { when (baseError) {
@@ -121,11 +150,9 @@ fun PhotoMaterialsScreen(
.fillMaxSize() .fillMaxSize()
) { ) {
repeat(3) { item(span = { GridItemSpan(3) }) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding())) Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
} }
}
items(items = screenState.materials) { item -> items(items = screenState.materials) { item ->
item as UiChatMaterial.Photo item as UiChatMaterial.Photo
AsyncImage( AsyncImage(
@@ -142,10 +169,45 @@ fun PhotoMaterialsScreen(
) )
) )
} }
repeat(3) { item(span = { GridItemSpan(3) }) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
Spacer(modifier = Modifier.height(32.dp))
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
gridState.scrollToItem(14)
gridState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item { item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
} }
item(span = { GridItemSpan(3) }) {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
} }
} }
@@ -19,7 +19,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -27,6 +33,10 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -51,6 +61,8 @@ import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -65,6 +77,7 @@ fun VideoMaterialsScreen(
setCanScrollBackward: (Boolean) -> Unit, setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit onPaginationConditionsMet: () -> Unit
) { ) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -75,6 +88,20 @@ fun VideoMaterialsScreen(
.collect(setCanScrollBackward) .collect(setCanScrollBackward)
} }
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when { when {
baseError != null -> { baseError != null -> {
when (baseError) { when (baseError) {
@@ -197,6 +224,37 @@ fun VideoMaterialsScreen(
} }
} }
} }
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item { item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
} }
@@ -1,5 +1,6 @@
package dev.meloda.fast.chatmaterials.util package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
@@ -11,11 +12,12 @@ import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain
import java.util.Locale import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial = fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
when (val type = this.attachment.type) { when (val type = this.attachment.type) {
AttachmentType.PHOTO -> { AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo( UiChatMaterial.Photo(
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty() previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
) )
} }
@@ -45,6 +47,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
builder.toString().format(Locale.getDefault(), *args.toTypedArray()) builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Video( UiChatMaterial.Video(
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(), previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(),
title = attachment.title, title = attachment.title,
views = attachment.views, views = attachment.views,
@@ -77,6 +80,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
builder.toString().format(Locale.getDefault(), *args.toTypedArray()) builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Audio( UiChatMaterial.Audio(
conversationMessageId = this.conversationMessageId,
previewUrl = null, previewUrl = null,
title = attachment.title, title = attachment.title,
artist = attachment.artist, artist = attachment.artist,
@@ -108,6 +112,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
} }
UiChatMaterial.File( UiChatMaterial.File(
conversationMessageId = this.conversationMessageId,
title = attachment.title, title = attachment.title,
previewUrl = previewUrl, previewUrl = previewUrl,
size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()), size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()),
@@ -119,6 +124,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
val attachment = this.attachment as VkLinkDomain val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link( UiChatMaterial.Link(
conversationMessageId = this.conversationMessageId,
title = attachment.title, title = attachment.title,
previewUrl = attachment.photo?.getMaxSize()?.url, previewUrl = attachment.photo?.getMaxSize()?.url,
url = attachment.url, url = attachment.url,
@@ -129,5 +135,8 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
) )
} }
else -> throw IllegalArgumentException("Unsupported type: $type") else -> {
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
} }
@@ -3,6 +3,7 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -51,7 +53,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
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.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -277,19 +278,28 @@ fun ConversationsScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column { Column {
AnimatedVisibility( // AnimatedVisibility(
visible = listState.isScrollingUp(), // visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), // enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) // exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
// ) {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
) { ) {
FloatingActionButton(onClick = onCreateChatButtonClicked) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24), painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button" contentDescription = "Add chat button"
) )
} }
} // }
Spacer(modifier = Modifier.height(LocalBottomPadding.current)) Spacer(modifier = Modifier.height(LocalBottomPadding.current))
} }
@@ -157,8 +157,8 @@ class FriendsViewModelImpl(
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient
screenState.value.friends.size >= LOAD_COUNT && screenState.value.friends.isNotEmpty()
imagesToPreload.setValue { imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100) response.mapNotNull(VkUser::photo100)