forked from melod1n/fast-messenger
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:
+60
-17
@@ -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
|
||||
}
|
||||
|
||||
+38
-2
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+179
-230
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+232
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user