Release 0.2.0 (#150)

Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-04-04 21:47:05 +03:00
committed by GitHub
parent 0eb3146428
commit 82fb78e9ea
279 changed files with 9171 additions and 4517 deletions
@@ -4,17 +4,19 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.MaterialType
import dev.meloda.fast.chatmaterials.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface ChatMaterialsViewModel {
val screenState: StateFlow<ChatMaterialsScreenState>
@@ -23,7 +25,7 @@ interface ChatMaterialsViewModel {
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onPaginationConditionsMet()
fun onRefresh()
@@ -33,6 +35,7 @@ interface ChatMaterialsViewModel {
}
class ChatMaterialsViewModelImpl(
private val materialType: MaterialType,
private val messagesUseCase: MessagesUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel(), ChatMaterialsViewModel {
@@ -50,15 +53,15 @@ class ChatMaterialsViewModelImpl(
screenState.setValue { old ->
old.copy(
peerId = arguments.peerId,
conversationMessageId = arguments.conversationMessageId
cmId = arguments.conversationMessageId
)
}
loadChatMaterials()
}
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.materials.size }
override fun onPaginationConditionsMet() {
currentOffset.setValue { old -> old + LOAD_COUNT }
loadChatMaterials()
}
@@ -75,31 +78,33 @@ class ChatMaterialsViewModelImpl(
loadChatMaterials(0)
}
private fun loadChatMaterials(
offset: Int = currentOffset.value
) {
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
attachmentTypes = listOf(materialType.toString()),
cmId = screenState.value.cmId
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.materials.size >= LOAD_COUNT
val paginationExhausted = !itemsCountSufficient
&& screenState.value.materials.isNotEmpty()
val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation)
val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation)
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
isPaginationExhausted = paginationExhausted,
cmId = if (loadedMaterials.size + offset > 200) {
currentOffset.setValue { 0 }
loadedMaterials.lastOrNull()?.conversationMessageId ?: -1
} else {
screenState.value.cmId
}
)
if (offset == 0) {
@@ -125,6 +130,44 @@ 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 {
const val LOAD_COUNT = 100
}
@@ -1,9 +1,45 @@
package dev.meloda.fast.chatmaterials.di
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import org.koin.androidx.viewmodel.dsl.viewModelOf
import dev.meloda.fast.chatmaterials.model.MaterialType
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val chatMaterialsModule = module {
viewModelOf(::ChatMaterialsViewModelImpl)
viewModel(named(MaterialType.PHOTO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.PHOTO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.AUDIO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.AUDIO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.VIDEO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.VIDEO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.FILE)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.FILE,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.LINK)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.LINK,
messagesUseCase = get(),
savedStateHandle = get()
)
}
}
@@ -9,8 +9,8 @@ data class ChatMaterialsScreenState(
val attachmentType: String,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val peerId: Int,
val conversationMessageId: Int
val peerId: Long,
val cmId: Long
) {
companion object {
@@ -21,7 +21,7 @@ data class ChatMaterialsScreenState(
isPaginating = false,
isPaginationExhausted = false,
peerId = -1,
conversationMessageId = -1
cmId = -1
)
}
}
@@ -0,0 +1,13 @@
package dev.meloda.fast.chatmaterials.model
enum class MaterialType {
PHOTO, VIDEO, AUDIO, FILE, LINK;
override fun toString(): String = when (this) {
PHOTO -> "photo"
VIDEO -> "video"
AUDIO -> "audio"
FILE -> "doc"
LINK -> "link"
}
}
@@ -1,28 +1,43 @@
package dev.meloda.fast.chatmaterials.model
sealed class UiChatMaterial {
sealed class UiChatMaterial(
open val conversationMessageId: Long
) {
data class Photo(
override val conversationMessageId: Long,
val previewUrl: String
) : UiChatMaterial()
) : UiChatMaterial(conversationMessageId)
data class Video(
val previewUrl: String
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val views: Int,
val duration: String
) : UiChatMaterial(conversationMessageId)
data class Audio(
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val artist: String,
val duration: String
) : UiChatMaterial()
) : UiChatMaterial(conversationMessageId)
data class File(
val title: String
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val size: String,
val extension: String
) : UiChatMaterial(conversationMessageId)
data class Link(
val title: String,
val previewUrl: String?
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String?,
val url: String,
val urlFirstChar: String
) : UiChatMaterial(conversationMessageId)
}
@@ -10,8 +10,8 @@ import kotlinx.serialization.Serializable
@Serializable
data class ChatMaterials(
val peerId: Int,
val conversationMessageId: Int
val peerId: Long,
val conversationMessageId: Long
) {
companion object {
fun from(savedStateHandle: SavedStateHandle) =
@@ -31,7 +31,7 @@ fun NavGraphBuilder.chatMaterialsScreen(
}
}
fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) {
fun NavController.navigateToChatMaterials(peerId: Long, conversationMessageId: Long) {
this.navigate(
ChatMaterials(
peerId = peerId,
@@ -1,93 +1,73 @@
package dev.meloda.fast.chatmaterials.presentation
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.ScrollableTabRow
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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
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.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.ui.R
import dev.meloda.fast.chatmaterials.model.MaterialType
import dev.meloda.fast.chatmaterials.presentation.materials.AudioMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.FileMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
import dev.meloda.fast.ui.R as UiR
@Composable
fun ChatMaterialsRoute(
onBack: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
ChatMaterialsScreen(
screenState = screenState,
onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked
)
}
@@ -99,55 +79,36 @@ fun ChatMaterialsRoute(
)
@Composable
fun ChatMaterialsScreen(
screenState: ChatMaterialsScreenState = ChatMaterialsScreenState.EMPTY,
onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current
val attachments = screenState.materials
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) {
onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
2 -> "audio"
3 -> "doc"
4 -> "link"
else -> ""
}
val titles = remember {
listOf(
UiR.string.chat_attachment_photos,
UiR.string.chat_attachment_videos,
UiR.string.chat_attachment_music,
UiR.string.chat_attachment_files,
UiR.string.chat_attachment_links,
)
}
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
val tabItems = remember {
titles.map { resId ->
TabItem(
titleResId = resId,
unselectedIconResId = null,
selectedIconResId = null
)
}
}
Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward")
var canScrollBackward by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
@@ -159,8 +120,7 @@ fun ChatMaterialsScreen(
)
val topBarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !canScrollBackward)
targetValue = if (currentTheme.enableBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
@@ -171,7 +131,13 @@ fun ChatMaterialsScreen(
)
)
val pullToRefreshState = rememberPullToRefreshState()
val pagerState = rememberPagerState(
pageCount = tabItems::size
)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
Scaffold(
topBar = {
@@ -181,11 +147,9 @@ fun ChatMaterialsScreen(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = hazeStyle
style = HazeMaterials.thick()
)
} else {
Modifier
}
} else Modifier
)
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.fillMaxWidth()
@@ -193,7 +157,7 @@ fun ChatMaterialsScreen(
TopAppBar(
title = {
Text(
text = "Chat Materials",
text = stringResource(UiR.string.chat_materials_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
@@ -210,162 +174,147 @@ fun ChatMaterialsScreen(
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 = {
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
if (currentTheme.enableBlur) {
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
}
)
}
}
}
)
ScrollableTabRow(
modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = MaterialTheme.colorScheme.primary
)
}
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == selectedTabIndex,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
) { padding ->
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
if (checkedTypeIndex in listOf(0, 1)) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
CompositionLocalProvider(LocalHazeState provides hazeState) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
when (index) {
0 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.PHOTO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
) {
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
onClick = {
if (item is UiChatMaterial.Photo) {
onPhotoClicked(item.previewUrl)
}
}
PhotoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onPhotoClicked = onPhotoClicked
)
}
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
onClick = {}
1 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.VIDEO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
VideoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
2 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.AUDIO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
AudioMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
3 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.FILE))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
FileMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
4 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.LINK))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LinkMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
else -> Unit
}
}
}
@@ -0,0 +1,232 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.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 {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Audio
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(42.dp)
.padding(4.dp),
painter = painterResource(UiR.drawable.round_play_arrow_24),
contentDescription = null,
tint = contentColorFor(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = item.artist,
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(text = item.duration)
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,264 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.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 {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.File
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
if (item.previewUrl != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(width = 64.dp, height = 48.dp),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
)
.size(width = 64.dp, height = 48.dp)
.padding(4.dp),
text = item.extension.uppercase(),
textAlign = TextAlign.Center,
lineHeight = 40.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = item.size,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,283 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LinkMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.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 {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Link
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
if (item.previewUrl != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(
width = 86.dp,
height = 64.dp
),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
)
.size(
width = 86.dp,
height = 64.dp
)
.padding(4.dp),
text = item.urlFirstChar,
textAlign = TextAlign.Center,
lineHeight = 56.sp,
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.titleLarge
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
if (item.title != null) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
}
LocalContentAlpha(
alpha = if (item.title != null) ContentAlpha.medium
else ContentAlpha.high
) {
Text(
text = item.url,
style = if (item.title != null) {
MaterialTheme.typography.bodyMedium
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = if (item.title != null) 1 else 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,207 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhotoMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPhotoClicked: (String) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val gridState = rememberLazyGridState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(gridState) {
snapshotFlow { gridState.canScrollBackward }
.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 {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item(span = { GridItemSpan(3) }) {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(items = screenState.materials) { item ->
item as UiChatMaterial.Photo
AsyncImage(
model = item.previewUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(
onClick = {
onPhotoClicked(item.previewUrl)
}
)
)
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
item(span = { GridItemSpan(3) }) {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,256 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.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 {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Video
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box {
Image(
modifier = Modifier
.fillMaxWidth(0.33f)
.height(64.dp)
.clip(RoundedCornerShape(4.dp)),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader
),
contentDescription = null,
contentScale = ContentScale.Crop
)
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 4.dp,
bottom = 4.dp
)
.clip(RoundedCornerShape(8.dp))
.background(
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
)
.padding(
vertical = 1.dp,
horizontal = 4.dp
),
text = item.duration,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.background
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = "${item.views} views",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -1,6 +1,8 @@
package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkAudioDomain
@@ -8,52 +10,133 @@ import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain
import java.text.SimpleDateFormat
import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
when (val type = this.attachment.type) {
AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo(
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
)
}
AttachmentType.VIDEO -> {
val attachment = this.attachment as VkVideoDomain
val duration = attachment.duration
val days = duration / (24 * 3600)
val hours = (duration % (24 * 3600)) / 3600
val minutes = (duration % 3600) / 60
val seconds = duration % 60
val args = mutableListOf<Int>()
if (days > 0) args.add(days)
if (hours > 0) args.add(hours)
args.add(minutes)
args.add(seconds)
val builder = StringBuilder()
if (days > 0) builder.append("%02d:")
if (hours > 0) builder.append("%02d:")
builder.append("%02d:%02d")
val formattedDuration =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Video(
previewUrl = attachment.images.firstOrNull()?.url.orEmpty()
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(),
title = attachment.title,
views = attachment.views,
duration = formattedDuration
)
}
AttachmentType.AUDIO -> {
val attachment = this.attachment as VkAudioDomain
val duration = attachment.duration
val days = duration / (24 * 3600)
val hours = (duration % (24 * 3600)) / 3600
val minutes = (duration % 3600) / 60
val seconds = duration % 60
val args = mutableListOf<Int>()
if (days > 0) args.add(days)
if (hours > 0) args.add(hours)
args.add(minutes)
args.add(seconds)
val builder = StringBuilder()
if (days > 0) builder.append("%02d:")
if (hours > 0) builder.append("%02d:")
builder.append("%d:%02d")
val formattedDuration =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Audio(
conversationMessageId = this.conversationMessageId,
previewUrl = null,
title = attachment.title,
artist = attachment.artist,
duration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(attachment.duration)
duration = formattedDuration
)
}
AttachmentType.FILE -> {
val attachment = this.attachment as VkFileDomain
val previewUrl: String? = when (val preview = attachment.preview) {
null -> null
else -> {
when {
preview.photo != null -> {
val size = preview.photo?.sizes?.maxByOrNull { it.width }
size?.src
}
preview.video != null -> {
val size = preview.video?.src
size
}
else -> null
}
}
}
UiChatMaterial.File(
title = attachment.title
conversationMessageId = this.conversationMessageId,
title = attachment.title,
previewUrl = previewUrl,
size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()),
extension = attachment.ext.take(4)
)
}
AttachmentType.LINK -> {
val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link(
title = attachment.title ?: attachment.url,
previewUrl = attachment.photo?.getMaxSize()?.url
conversationMessageId = this.conversationMessageId,
title = attachment.title,
previewUrl = attachment.photo?.getMaxSize()?.url,
url = attachment.url,
urlFirstChar = attachment.url.replaceFirst("http://", "")
.replaceFirst("https://", "")
.take(1)
.uppercase()
)
}
else -> throw IllegalArgumentException("Unsupported type: $type")
else -> {
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
}