reworked chat materials screen and some fixes

This commit is contained in:
2025-03-27 02:27:19 +03:00
parent 37a654790c
commit 807c23926e
22 changed files with 1566 additions and 363 deletions
@@ -1,75 +1,78 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkVideoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkVideoData( data class VkVideoData(
val id: Int, @Json(name = "id") val id: Int,
val title: String, @Json(name = "title") val title: String,
val width: Int?, @Json(name = "width") val width: Int?,
val height: Int?, @Json(name = "height") val height: Int?,
val duration: Int, @Json(name = "duration") val duration: Int,
val date: Int, @Json(name = "date") val date: Int,
val comments: Int?, @Json(name = "comments") val comments: Int?,
val description: String?, @Json(name = "description") val description: String?,
val player: String?, @Json(name = "player") val player: String?,
val added: Int?, @Json(name = "added") val added: Int?,
val type: String, @Json(name = "type") val type: String,
val views: Int, @Json(name = "views") val views: Int,
val access_key: String?, @Json(name = "access_key") val accessKey: String?,
val owner_id: Int, @Json(name = "owner_id") val ownerId: Int,
val is_favorite: Boolean?, @Json(name = "is_favorite") val isFavorite: Boolean?,
val image: List<Image>?, @Json(name = "image") val image: List<Image>?,
val first_frame: List<FirstFrame>?, @Json(name = "first_frame") val firstFrame: List<FirstFrame>?,
val files: File? @Json(name = "files") val files: File?
) : VkAttachmentData { ) : VkAttachmentData {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Image( data class Image(
val width: Int, @Json(name = "width") val width: Int,
val height: Int, @Json(name = "height") val height: Int,
val url: String, @Json(name = "url") val url: String,
val with_padding: Int? @Json(name = "with_padding") val withPadding: Int?
) { ) {
fun asVideoImage() = VkVideoDomain.VideoImage( fun asVideoImage() = VkVideoDomain.VideoImage(
width = width, width = width,
height = height, height = height,
url = url, url = url,
withPadding = with_padding == 1 withPadding = withPadding == 1
) )
} }
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class FirstFrame( data class FirstFrame(
val height: Int, @Json(name = "height") val height: Int,
val width: Int, @Json(name = "width") val width: Int,
val url: String @Json(name = "url") val url: String
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class File( data class File(
val mp4_240: String?, @Json(name = "mp4_240") val mp4240: String?,
val mp4_360: String?, @Json(name = "mp4_360") val mp4360: String?,
val mp4_480: String?, @Json(name = "mp4_480") val mp4480: String?,
val mp4_720: String?, @Json(name = "mp4_720") val mp4720: String?,
val mp4_1080: String?, @Json(name = "mp4_1080") val mp41080: String?,
val mp4_1440: String?, @Json(name = "mp4_1440") val mp41440: String?,
val hls: String?, @Json(name = "hls") val hls: String?,
val dash_uni: String?, @Json(name = "dash_uni") val dashUni: String?,
val dash_sep: String?, @Json(name = "dash_sep") val dashSep: String?,
val hls_ondemand: String?, @Json(name = "hls_ondemand") val hlsOnDemand: String?,
val dash_ondemand: String?, @Json(name = "dash_ondemand") val dashOnDemand: String?,
val failover_host: String? @Json(name = "failover_host") val failOverHost: String?
) )
fun toDomain() = VkVideoDomain( fun toDomain() = VkVideoDomain(
id = id, id = id,
ownerId = owner_id, ownerId = ownerId,
images = image.orEmpty().map { it.asVideoImage() }, images = image.orEmpty().map { it.asVideoImage() },
firstFrames = first_frame, firstFrames = firstFrame,
accessKey = access_key, accessKey = accessKey,
title = title title = title,
views = views,
duration = duration
) )
} }
@@ -12,6 +12,8 @@ data class VkVideoDomain(
val firstFrames: List<VkVideoData.FirstFrame>?, val firstFrames: List<VkVideoData.FirstFrame>?,
val accessKey: String?, val accessKey: String?,
val title: String, val title: String,
val views: Int,
val duration: Int
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO override val type: AttachmentType = AttachmentType.VIDEO
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M9 13V5C9 3.9 9.9 3 11 3H20C21.1 3 22 3.9 22 5V11H18.57L17.29 9.26C17.23 9.17 17.11 9.17 17.05 9.26L15.06 12C15 12.06 14.88 12.07 14.82 12L13.39 10.25C13.33 10.18 13.22 10.18 13.16 10.25L11.05 12.91C10.97 13 11.04 13.15 11.16 13.15H17.5V15H11C9.89 15 9 14.11 9 13M6 22V21H4V22H2V2H4V3H6V2H8.39C7.54 2.74 7 3.8 7 5V13C7 15.21 8.79 17 11 17H15.7C14.67 17.83 14 19.08 14 20.5C14 21.03 14.11 21.53 14.28 22H6M4 7H6V5H4V7M4 11H6V9H4V11M4 15H6V13H4V15M6 19V17H4V19H6M23 13V15H21V20.5C21 21.88 19.88 23 18.5 23S16 21.88 16 20.5 17.12 18 18.5 18C18.86 18 19.19 18.07 19.5 18.21V13H23Z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,6.82v10.36c0,0.79 0.87,1.27 1.54,0.84l8.14,-5.18c0.62,-0.39 0.62,-1.29 0,-1.69L9.54,5.98C8.87,5.55 8,6.03 8,6.82z" />
</vector>
@@ -218,4 +218,15 @@
<string name="title_create_chat">Создать чат</string> <string name="title_create_chat">Создать чат</string>
<string name="action_create">Создать</string> <string name="action_create">Создать</string>
<string name="create_chat_title">Название</string> <string name="create_chat_title">Название</string>
<string name="chat_materials_title">Вложения чата</string>
<string name="chat_materials_action_title">Вложения</string>
<string name="friends_order_priority">Приоритет</string>
<string name="friends_order_name">Имя</string>
<string name="friends_order_random">Случайно</string>
<string name="friends_order_by_title">Упорядочить по</string>
<string name="chat_attachment_photos">Фото</string>
<string name="chat_attachment_videos">Видео</string>
<string name="chat_attachment_music">Музыка</string>
<string name="chat_attachment_files">Файлы</string>
<string name="chat_attachment_links">Ссылки</string>
</resources> </resources>
+13
View File
@@ -283,4 +283,17 @@
<string name="title_create_chat">Create chat</string> <string name="title_create_chat">Create chat</string>
<string name="action_create">Create</string> <string name="action_create">Create</string>
<string name="create_chat_title">Title</string> <string name="create_chat_title">Title</string>
<string name="chat_materials_title">Chat materials</string>
<string name="chat_materials_action_title">Materials</string>
<string name="friends_order_priority">Priority</string>
<string name="friends_order_name">Name</string>
<string name="friends_order_random">Random</string>
<string name="friends_order_mobile" translatable="false">Mobile</string>
<string name="friends_order_smart" translatable="false">Smart</string>
<string name="friends_order_by_title">Order by</string>
<string name="chat_attachment_photos">Photos</string>
<string name="chat_attachment_videos">Videos</string>
<string name="chat_attachment_music">Music</string>
<string name="chat_attachment_files">Files</string>
<string name="chat_attachment_links">Links</string>
</resources> </resources>
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState 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.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
@@ -23,7 +24,7 @@ interface ChatMaterialsViewModel {
val currentOffset: StateFlow<Int> val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition() fun onPaginationConditionsMet()
fun onRefresh() fun onRefresh()
@@ -33,6 +34,7 @@ interface ChatMaterialsViewModel {
} }
class ChatMaterialsViewModelImpl( class ChatMaterialsViewModelImpl(
private val materialType: MaterialType,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel(), ChatMaterialsViewModel { ) : ViewModel(), ChatMaterialsViewModel {
@@ -57,7 +59,7 @@ class ChatMaterialsViewModelImpl(
loadChatMaterials() loadChatMaterials()
} }
override fun onMetPaginationCondition() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.materials.size } currentOffset.update { screenState.value.materials.size }
loadChatMaterials() loadChatMaterials()
} }
@@ -75,14 +77,12 @@ class ChatMaterialsViewModelImpl(
loadChatMaterials(0) loadChatMaterials(0)
} }
private fun loadChatMaterials( private fun loadChatMaterials(offset: Int = currentOffset.value) {
offset: Int = currentOffset.value
) {
messagesUseCase.getHistoryAttachments( messagesUseCase.getHistoryAttachments(
peerId = screenState.value.peerId, peerId = screenState.value.peerId,
count = LOAD_COUNT, count = LOAD_COUNT,
offset = offset, offset = offset,
attachmentTypes = listOf(screenState.value.attachmentType), attachmentTypes = listOf(materialType.toString()),
conversationMessageId = screenState.value.conversationMessageId conversationMessageId = screenState.value.conversationMessageId
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
@@ -126,6 +126,6 @@ class ChatMaterialsViewModelImpl(
} }
companion object { companion object {
const val LOAD_COUNT = 100 const val LOAD_COUNT = 30
} }
} }
@@ -1,9 +1,45 @@
package dev.meloda.fast.chatmaterials.di package dev.meloda.fast.chatmaterials.di
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl 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 import org.koin.dsl.module
val chatMaterialsModule = 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()
)
}
} }
@@ -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"
}
}
@@ -7,7 +7,10 @@ sealed class UiChatMaterial {
) : UiChatMaterial() ) : UiChatMaterial()
data class Video( data class Video(
val previewUrl: String val previewUrl: String?,
val title: String,
val views: Int,
val duration: String
) : UiChatMaterial() ) : UiChatMaterial()
data class Audio( data class Audio(
@@ -18,11 +21,16 @@ sealed class UiChatMaterial {
) : UiChatMaterial() ) : UiChatMaterial()
data class File( data class File(
val title: String val previewUrl: String?,
val title: String,
val size: String,
val extension: String
) : UiChatMaterial() ) : UiChatMaterial()
data class Link( data class Link(
val title: String, val previewUrl: String?,
val previewUrl: String? val title: String?,
val url: String,
val urlFirstChar: String
) : UiChatMaterial() ) : UiChatMaterial()
} }
@@ -1,93 +1,69 @@
package dev.meloda.fast.chatmaterials.presentation package dev.meloda.fast.chatmaterials.presentation
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
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.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState import dev.meloda.fast.chatmaterials.model.MaterialType
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.presentation.materials.AudioMaterialsScreen
import dev.meloda.fast.ui.R 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.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun ChatMaterialsRoute( fun ChatMaterialsRoute(
onBack: () -> Unit, onBack: () -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
ChatMaterialsScreen( ChatMaterialsScreen(
screenState = screenState,
onBack = onBack, onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
) )
} }
@@ -99,55 +75,36 @@ fun ChatMaterialsRoute(
) )
@Composable @Composable
fun ChatMaterialsScreen( fun ChatMaterialsScreen(
screenState: ChatMaterialsScreenState = ChatMaterialsScreenState.EMPTY,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {} onPhotoClicked: (url: String) -> Unit = {}
) { ) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val attachments = screenState.materials
var moreClearBlur by rememberSaveable {
mutableStateOf(false)
}
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val hazeStyle = if (moreClearBlur) HazeMaterials.ultraThin() else HazeMaterials.regular()
var dropDownMenuExpanded by remember { val titles = remember {
mutableStateOf(false) listOf(
} UiR.string.chat_attachment_photos,
var checkedTypeIndex by rememberSaveable { UiR.string.chat_attachment_videos,
mutableIntStateOf(0) UiR.string.chat_attachment_music,
} UiR.string.chat_attachment_files,
UiR.string.chat_attachment_links,
LaunchedEffect(checkedTypeIndex) {
onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
2 -> "audio"
3 -> "doc"
4 -> "link"
else -> ""
}
) )
} }
val titles = listOf("Photos", "Videos", "Audios")//, "Files", "Links") val tabItems = remember {
titles.map { resId ->
val listState = rememberLazyListState() TabItem(
val gridState = rememberLazyGridState() titleResId = resId,
unselectedIconResId = null,
val canScrollBackward = when (checkedTypeIndex) { selectedIconResId = null
in 0..1 -> gridState.canScrollBackward )
else -> listState.canScrollBackward }
} }
Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward") var canScrollBackward by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState( val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
@@ -160,10 +117,8 @@ fun ChatMaterialsScreen(
val topBarContainerColor by animateColorAsState( val topBarContainerColor by animateColorAsState(
targetValue = targetValue =
if (currentTheme.enableBlur || !canScrollBackward) if (currentTheme.enableBlur || !canScrollBackward) MaterialTheme.colorScheme.surface
MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
animationSpec = tween( animationSpec = tween(
durationMillis = 200, durationMillis = 200,
@@ -171,7 +126,13 @@ fun ChatMaterialsScreen(
) )
) )
val pullToRefreshState = rememberPullToRefreshState() val pagerState = rememberPagerState(
pageCount = tabItems::size
)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -181,11 +142,9 @@ fun ChatMaterialsScreen(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeEffect( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = hazeStyle style = HazeMaterials.thick()
) )
} else { } else Modifier
Modifier
}
) )
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.fillMaxWidth() .fillMaxWidth()
@@ -193,7 +152,7 @@ fun ChatMaterialsScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "Chat Materials", text = stringResource(UiR.string.chat_materials_title),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
@@ -210,163 +169,139 @@ fun ChatMaterialsScreen(
contentDescription = null contentDescription = null
) )
} }
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
} }
)
PrimaryTabRow(
modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent
) { ) {
Icon( tabItems.forEachIndexed { index, item ->
imageVector = Icons.Outlined.MoreVert, Tab(
contentDescription = "Options button" selected = index == selectedTabIndex,
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = { onClick = {
onRefreshDropdownItemClicked() scope.launch {
dropDownMenuExpanded = false pagerState.animateScrollToPage(index)
}
}, },
text = { text = {
Text(text = stringResource(id = R.string.action_refresh)) item.titleResId?.let { resId ->
}, Text(text = stringResource(id = resId))
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
}
)
}
} }
} }
) )
} }
} }
}
}
) { padding -> ) { padding ->
PullToRefreshBox( HorizontalPager(
modifier = Modifier state = pagerState,
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) ) { index ->
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)), when (index) {
state = pullToRefreshState, 0 -> {
isRefreshing = screenState.isLoading, val viewModel: ChatMaterialsViewModel =
onRefresh = onRefresh, koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.PHOTO))
indicator = { val screenState by viewModel.screenState.collectAsStateWithLifecycle()
PullToRefreshDefaults.Indicator( val baseError by viewModel.baseError.collectAsStateWithLifecycle()
state = pullToRefreshState, val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
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()
) { PhotoMaterialsScreen(
repeat(3) { modifier = Modifier,
item { screenState = screenState,
Spacer(modifier = Modifier.height(padding.calculateTopPadding())) baseError = baseError,
} padding = padding,
} onRefresh = viewModel::onRefresh,
items(attachments) { item -> onSessionExpiredLogOutButtonClicked = { },
ChatMaterialItem( setCanScrollBackward = { canScrollBackward = it },
item = item, canPaginate = canPaginate,
onClick = { onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
if (item is UiChatMaterial.Photo) { onPhotoClicked = onPhotoClicked
onPhotoClicked(item.previewUrl)
}
}
) )
} }
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()
) { 1 -> {
item { val viewModel: ChatMaterialsViewModel =
Spacer(modifier = Modifier.height(padding.calculateTopPadding())) koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.VIDEO))
} val screenState by viewModel.screenState.collectAsStateWithLifecycle()
items(attachments) { item -> val baseError by viewModel.baseError.collectAsStateWithLifecycle()
ChatMaterialItem( val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
item = item,
onClick = {} 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,248 @@
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.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
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 -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
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,224 @@
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.material3.ExperimentalMaterial3Api
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
@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 hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(R.string.session_expired),
buttonText = stringResource(R.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(R.string.try_again),
onButtonClick = onRefresh
)
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,243 @@
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.material3.ExperimentalMaterial3Api
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
@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 hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(R.string.session_expired),
buttonText = stringResource(R.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(R.string.try_again),
onButtonClick = onRefresh
)
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,161 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
@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 hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val gridState = rememberLazyGridState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(gridState) {
snapshotFlow { gridState.canScrollBackward }
.collect(setCanScrollBackward)
}
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(R.string.session_expired),
buttonText = stringResource(R.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(R.string.try_again),
onButtonClick = onRefresh
)
}
}
}
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()
) {
repeat(3) {
item {
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)
}
)
)
}
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,214 @@
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.material3.ExperimentalMaterial3Api
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.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.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
@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 hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(R.string.session_expired),
buttonText = stringResource(R.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(R.string.try_again),
onButtonClick = onRefresh
)
}
}
}
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 {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -1,6 +1,7 @@
package dev.meloda.fast.chatmaterials.util package dev.meloda.fast.chatmaterials.util
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkAudioDomain import dev.meloda.fast.model.api.domain.VkAudioDomain
@@ -8,7 +9,6 @@ import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain
import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial = fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
@@ -22,36 +22,110 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
AttachmentType.VIDEO -> { AttachmentType.VIDEO -> {
val attachment = this.attachment as VkVideoDomain 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( UiChatMaterial.Video(
previewUrl = attachment.images.firstOrNull()?.url.orEmpty() previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(),
title = attachment.title,
views = attachment.views,
duration = formattedDuration
) )
} }
AttachmentType.AUDIO -> { AttachmentType.AUDIO -> {
val attachment = this.attachment as VkAudioDomain 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( UiChatMaterial.Audio(
previewUrl = null, previewUrl = null,
title = attachment.title, title = attachment.title,
artist = attachment.artist, artist = attachment.artist,
duration = SimpleDateFormat( duration = formattedDuration
"mm:ss",
Locale.getDefault()
).format(attachment.duration)
) )
} }
AttachmentType.FILE -> { AttachmentType.FILE -> {
val attachment = this.attachment as VkFileDomain 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( UiChatMaterial.File(
title = attachment.title title = attachment.title,
previewUrl = previewUrl,
size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()),
extension = attachment.ext.take(4)
) )
} }
AttachmentType.LINK -> { AttachmentType.LINK -> {
val attachment = this.attachment as VkLinkDomain val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link( UiChatMaterial.Link(
title = attachment.title ?: attachment.url, title = attachment.title,
previewUrl = attachment.photo?.getMaxSize()?.url previewUrl = attachment.photo?.getMaxSize()?.url,
url = attachment.url,
urlFirstChar = attachment.url.replaceFirst("http://", "")
.replaceFirst("https://", "")
.take(1)
.uppercase()
) )
} }
@@ -13,7 +13,7 @@ data class ConversationsScreenState(
val isPaginationExhausted: Boolean, val isPaginationExhausted: Boolean,
val profileImageUrl: String?, val profileImageUrl: String?,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int val scrollOffset: Int,
) { ) {
companion object { companion object {
@@ -137,7 +137,6 @@ fun ConversationsScreen(
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {} setScrollOffset: (Int) -> Unit = {}
) { ) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) { val maxLines by remember(currentTheme) {
@@ -1,6 +1,7 @@
package dev.meloda.fast.conversations.presentation package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -79,8 +80,6 @@ fun CreateChatRoute(
onChatCreated: (Int) -> Unit, onChatCreated: (Int) -> Unit,
viewModel: CreateChatViewModel viewModel: CreateChatViewModel
) { ) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
@@ -148,20 +147,24 @@ fun CreateChatScreen(
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
val toolbarColorAlpha by animateFloatAsState( val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f, targetValue = if (!currentTheme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50) animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
) )
val toolbarContainerColor by animateColorAsState( val topBarContainerColor by animateColorAsState(
targetValue = targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward) if (currentTheme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface
MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50) animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
) )
Scaffold( Scaffold(
@@ -171,11 +174,7 @@ fun CreateChatScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
)
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeEffect( Modifier.hazeEffect(
@@ -205,11 +204,7 @@ fun CreateChatScreen(
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
containerColor = toolbarContainerColor.copy(
alpha = 0f
)
),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -24,14 +24,13 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -50,7 +49,7 @@ import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -60,10 +59,7 @@ fun FriendsRoute(
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit, onMessageClicked: (userId: Int) -> Unit,
) { ) {
var selectedTabIndex by rememberSaveable { val scope = rememberCoroutineScope()
mutableIntStateOf(0)
}
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
@@ -107,20 +103,36 @@ fun FriendsRoute(
) )
} }
val pagerState = rememberPagerState(pageCount = tabItems::size)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
var orderType: String by remember { mutableStateOf("hints") } var orderType: String by remember { mutableStateOf("hints") }
var showOrderDialog by remember { mutableStateOf(false) } var showOrderDialog by remember { mutableStateOf(false) }
val orderItems = remember { val orderPriority = stringResource(UiR.string.friends_order_priority)
mapOf( val orderName = stringResource(UiR.string.friends_order_name)
"hints" to "Priority", val orderRandom = stringResource(UiR.string.friends_order_random)
"name" to "Name", val orderMobile = stringResource(UiR.string.friends_order_mobile)
"random" to "Random", val orderSmart = stringResource(UiR.string.friends_order_smart)
"mobile" to "Mobile",
"smart" to "Smart" val orderTitleItems = remember {
ImmutableList.of(
orderPriority,
orderName,
orderRandom,
orderMobile,
orderSmart
) )
} }
val orderItems = remember {
listOf("hints", "name", "random", "mobile", "smart")
}
var selectedIndex by remember { var selectedIndex by remember {
mutableIntStateOf(0) mutableIntStateOf(0)
} }
@@ -130,17 +142,16 @@ fun FriendsRoute(
onDismissRequest = { showOrderDialog = false }, onDismissRequest = { showOrderDialog = false },
confirmText = stringResource(R.string.ok), confirmText = stringResource(R.string.ok),
confirmAction = { confirmAction = {
orderType = orderType = orderItems[selectedIndex]
orderItems.keys.toCollection(mutableListOf())[selectedIndex]
}, },
cancelText = stringResource(R.string.cancel), cancelText = stringResource(R.string.cancel),
selectionType = SelectionType.Single, selectionType = SelectionType.Single,
items = ImmutableList.copyOf(orderItems.values), items = orderTitleItems,
preSelectedItems = ImmutableList.of(selectedIndex), preSelectedItems = ImmutableList.of(selectedIndex),
onItemClick = { onItemClick = {
selectedIndex = it selectedIndex = it
}, },
title = "Order type", title = stringResource(UiR.string.friends_order_by_title),
actionInvokeDismiss = ActionInvokeDismiss.Always actionInvokeDismiss = ActionInvokeDismiss.Always
) )
} }
@@ -199,8 +210,8 @@ fun FriendsRoute(
Tab( Tab(
selected = index == selectedTabIndex, selected = index == selectedTabIndex,
onClick = { onClick = {
if (selectedTabIndex != index) { scope.launch {
selectedTabIndex = index pagerState.animateScrollToPage(index)
} }
}, },
text = { text = {
@@ -214,21 +225,6 @@ fun FriendsRoute(
} }
} }
) { padding -> ) { padding ->
val pagerState = rememberPagerState(
initialPage = selectedTabIndex
) {
tabItems.size
}
LaunchedEffect(selectedTabIndex) {
pagerState.animateScrollToPage(selectedTabIndex)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.collect { selectedTabIndex = it }
}
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -229,8 +229,7 @@ fun MessagesHistoryScreen(
.fillMaxWidth(), .fillMaxWidth(),
title = { title = {
Row( Row(
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (selectedMessages.isEmpty()) { if (selectedMessages.isEmpty()) {
@@ -332,6 +331,9 @@ fun MessagesHistoryScreen(
) )
} }
} else { } else {
if (screenState.isLoading) {
return@TopAppBar
}
IconButton( IconButton(
onClick = { dropDownMenuExpanded = true } onClick = { dropDownMenuExpanded = true }
) { ) {
@@ -362,7 +364,13 @@ fun MessagesHistoryScreen(
) )
}, },
text = { text = {
Text(text = "Materials") Text(text = stringResource(UiR.string.chat_materials_action_title))
},
leadingIcon = {
Icon(
painter = painterResource(UiR.drawable.ic_multimedia),
contentDescription = null
)
} }
) )
DropdownMenuItem( DropdownMenuItem(
@@ -371,7 +379,7 @@ fun MessagesHistoryScreen(
dropDownMenuExpanded = false dropDownMenuExpanded = false
}, },
text = { text = {
Text(text = "Refresh") Text(text = stringResource(UiR.string.action_refresh))
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(