From 807c23926ea3366c9b841da84611c3bbe0b87737 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Thu, 27 Mar 2025 02:27:19 +0300 Subject: [PATCH] reworked chat materials screen and some fixes --- .../meloda/fast/model/api/data/VkVideoData.kt | 87 ++-- .../fast/model/api/domain/VkVideoDomain.kt | 2 + .../src/main/res/drawable/ic_multimedia.xml | 9 + .../main/res/drawable/round_play_arrow_24.xml | 11 + core/ui/src/main/res/values-ru/strings.xml | 11 + core/ui/src/main/res/values/strings.xml | 13 + .../chatmaterials/ChatMaterialsViewModel.kt | 14 +- .../chatmaterials/di/ChatMaterialsModule.kt | 40 +- .../fast/chatmaterials/model/MaterialType.kt | 13 + .../chatmaterials/model/UiChatMaterial.kt | 16 +- .../presentation/ChatMaterialsScreen.kt | 407 ++++++++---------- .../materials/AudioMaterialsScreen.kt | 248 +++++++++++ .../materials/FileMaterialsScreen.kt | 224 ++++++++++ .../materials/LinkMaterialsScreen.kt | 243 +++++++++++ .../materials/PhotoMaterialsScreen.kt | 161 +++++++ .../materials/VideoMaterialsScreen.kt | 214 +++++++++ .../chatmaterials/util/ChatMaterialMapper.kt | 92 +++- .../model/ConversationsScreenState.kt | 2 +- .../presentation/ConversationsScreen.kt | 1 - .../presentation/CreateChatScreen.kt | 37 +- .../friends/presentation/RootFriendsScreen.kt | 68 ++- .../presentation/MessagesHistoryScreen.kt | 16 +- 22 files changed, 1566 insertions(+), 363 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_multimedia.xml create mode 100644 core/ui/src/main/res/drawable/round_play_arrow_24.xml create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt create mode 100644 feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt index 01bbfa3b..1053fda2 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt @@ -1,75 +1,78 @@ package dev.meloda.fast.model.api.data +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.domain.VkVideoDomain @JsonClass(generateAdapter = true) data class VkVideoData( - val id: Int, - val title: String, - val width: Int?, - val height: Int?, - val duration: Int, - val date: Int, - val comments: Int?, - val description: String?, - val player: String?, - val added: Int?, - val type: String, - val views: Int, - val access_key: String?, - val owner_id: Int, - val is_favorite: Boolean?, - val image: List?, - val first_frame: List?, - val files: File? + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "width") val width: Int?, + @Json(name = "height") val height: Int?, + @Json(name = "duration") val duration: Int, + @Json(name = "date") val date: Int, + @Json(name = "comments") val comments: Int?, + @Json(name = "description") val description: String?, + @Json(name = "player") val player: String?, + @Json(name = "added") val added: Int?, + @Json(name = "type") val type: String, + @Json(name = "views") val views: Int, + @Json(name = "access_key") val accessKey: String?, + @Json(name = "owner_id") val ownerId: Int, + @Json(name = "is_favorite") val isFavorite: Boolean?, + @Json(name = "image") val image: List?, + @Json(name = "first_frame") val firstFrame: List?, + @Json(name = "files") val files: File? ) : VkAttachmentData { @JsonClass(generateAdapter = true) data class Image( - val width: Int, - val height: Int, - val url: String, - val with_padding: Int? + @Json(name = "width") val width: Int, + @Json(name = "height") val height: Int, + @Json(name = "url") val url: String, + @Json(name = "with_padding") val withPadding: Int? ) { fun asVideoImage() = VkVideoDomain.VideoImage( width = width, height = height, url = url, - withPadding = with_padding == 1 + withPadding = withPadding == 1 ) } @JsonClass(generateAdapter = true) data class FirstFrame( - val height: Int, - val width: Int, - val url: String + @Json(name = "height") val height: Int, + @Json(name = "width") val width: Int, + @Json(name = "url") val url: String ) @JsonClass(generateAdapter = true) data class File( - val mp4_240: String?, - val mp4_360: String?, - val mp4_480: String?, - val mp4_720: String?, - val mp4_1080: String?, - val mp4_1440: String?, - val hls: String?, - val dash_uni: String?, - val dash_sep: String?, - val hls_ondemand: String?, - val dash_ondemand: String?, - val failover_host: String? + @Json(name = "mp4_240") val mp4240: String?, + @Json(name = "mp4_360") val mp4360: String?, + @Json(name = "mp4_480") val mp4480: String?, + @Json(name = "mp4_720") val mp4720: String?, + @Json(name = "mp4_1080") val mp41080: String?, + @Json(name = "mp4_1440") val mp41440: String?, + @Json(name = "hls") val hls: String?, + @Json(name = "dash_uni") val dashUni: String?, + @Json(name = "dash_sep") val dashSep: String?, + @Json(name = "hls_ondemand") val hlsOnDemand: String?, + @Json(name = "dash_ondemand") val dashOnDemand: String?, + @Json(name = "failover_host") val failOverHost: String? ) fun toDomain() = VkVideoDomain( id = id, - ownerId = owner_id, + ownerId = ownerId, images = image.orEmpty().map { it.asVideoImage() }, - firstFrames = first_frame, - accessKey = access_key, - title = title + firstFrames = firstFrame, + accessKey = accessKey, + title = title, + views = views, + duration = duration ) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt index 10f2c1f0..4fdd8f37 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt @@ -12,6 +12,8 @@ data class VkVideoDomain( val firstFrames: List?, val accessKey: String?, val title: String, + val views: Int, + val duration: Int ) : VkAttachment { override val type: AttachmentType = AttachmentType.VIDEO diff --git a/core/ui/src/main/res/drawable/ic_multimedia.xml b/core/ui/src/main/res/drawable/ic_multimedia.xml new file mode 100644 index 00000000..01a283ce --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_multimedia.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/round_play_arrow_24.xml b/core/ui/src/main/res/drawable/round_play_arrow_24.xml new file mode 100644 index 00000000..75ff5f1d --- /dev/null +++ b/core/ui/src/main/res/drawable/round_play_arrow_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index c137d3b7..bfd5122e 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -218,4 +218,15 @@ Создать чат Создать Название + Вложения чата + Вложения + Приоритет + Имя + Случайно + Упорядочить по + Фото + Видео + Музыка + Файлы + Ссылки diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index a9f1c0f4..65d4bcd3 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -283,4 +283,17 @@ Create chat Create Title + Chat materials + Materials + Priority + Name + Random + Mobile + Smart + Order by + Photos + Videos + Music + Files + Links diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt index 12638fff..725a650f 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/ChatMaterialsViewModel.kt @@ -4,6 +4,7 @@ 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 @@ -23,7 +24,7 @@ interface ChatMaterialsViewModel { val currentOffset: StateFlow val canPaginate: StateFlow - fun onMetPaginationCondition() + fun onPaginationConditionsMet() fun onRefresh() @@ -33,6 +34,7 @@ interface ChatMaterialsViewModel { } class ChatMaterialsViewModelImpl( + private val materialType: MaterialType, private val messagesUseCase: MessagesUseCase, savedStateHandle: SavedStateHandle ) : ViewModel(), ChatMaterialsViewModel { @@ -57,7 +59,7 @@ class ChatMaterialsViewModelImpl( loadChatMaterials() } - override fun onMetPaginationCondition() { + override fun onPaginationConditionsMet() { currentOffset.update { screenState.value.materials.size } loadChatMaterials() } @@ -75,14 +77,12 @@ 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), + attachmentTypes = listOf(materialType.toString()), conversationMessageId = screenState.value.conversationMessageId ).listenValue(viewModelScope) { state -> state.processState( @@ -126,6 +126,6 @@ class ChatMaterialsViewModelImpl( } companion object { - const val LOAD_COUNT = 100 + const val LOAD_COUNT = 30 } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt index 95f2222b..6fcfcb13 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/di/ChatMaterialsModule.kt @@ -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() + ) + } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt new file mode 100644 index 00000000..9593c3a9 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/MaterialType.kt @@ -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" + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt index 788d900d..035078db 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/model/UiChatMaterial.kt @@ -7,7 +7,10 @@ sealed class UiChatMaterial { ) : UiChatMaterial() data class Video( - val previewUrl: String + val previewUrl: String?, + val title: String, + val views: Int, + val duration: String ) : UiChatMaterial() data class Audio( @@ -18,11 +21,16 @@ sealed class UiChatMaterial { ) : UiChatMaterial() data class File( - val title: String + val previewUrl: String?, + val title: String, + val size: String, + val extension: String ) : UiChatMaterial() data class Link( - val title: String, - val previewUrl: String? + val previewUrl: String?, + val title: String?, + val url: String, + val urlFirstChar: String ) : UiChatMaterial() } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt index f7acddf4..4abe439d 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialsScreen.kt @@ -1,93 +1,69 @@ 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.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab 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.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.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() ) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() ChatMaterialsScreen( - screenState = screenState, onBack = onBack, - onTypeChanged = viewModel::onTypeChanged, - onRefreshDropdownItemClicked = viewModel::onRefresh, - onRefresh = viewModel::onRefresh, onPhotoClicked = onPhotoClicked ) } @@ -99,55 +75,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, @@ -160,10 +117,8 @@ fun ChatMaterialsScreen( val topBarContainerColor by animateColorAsState( targetValue = - if (currentTheme.enableBlur || !canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + if (currentTheme.enableBlur || !canScrollBackward) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), label = "toolbarColorAlpha", animationSpec = tween( 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( topBar = { @@ -181,11 +142,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 +152,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,163 +169,139 @@ 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 - } - ) - } - - } } ) + PrimaryTabRow( + modifier = Modifier.fillMaxWidth(), + selectedTabIndex = selectedTabIndex, + containerColor = Color.Transparent + ) { + 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() + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { index -> + when (index) { + 0 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(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) - } - } - ) - } - repeat(3) { - item { - Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) - } - } + PhotoMaterialsScreen( + modifier = Modifier, + screenState = screenState, + baseError = baseError, + padding = padding, + onRefresh = viewModel::onRefresh, + onSessionExpiredLogOutButtonClicked = { }, + setCanScrollBackward = { canScrollBackward = it }, + canPaginate = canPaginate, + onPaginationConditionsMet = viewModel::onPaginationConditionsMet, + onPhotoClicked = onPhotoClicked + ) } - } 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 = {} - ) - } - item { - Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) - } + 1 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(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 + ) } + + 2 -> { + val viewModel: ChatMaterialsViewModel = + koinViewModel(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(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(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 } } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt new file mode 100644 index 00000000..c5aa4740 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/AudioMaterialsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt new file mode 100644 index 00000000..84d91ae5 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/FileMaterialsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt new file mode 100644 index 00000000..c0432f1b --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/LinkMaterialsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt new file mode 100644 index 00000000..f3d3f1e2 --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/PhotoMaterialsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt new file mode 100644 index 00000000..d939070c --- /dev/null +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/materials/VideoMaterialsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt index 484e6a6d..8293a8fb 100644 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt +++ b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/util/ChatMaterialMapper.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.chatmaterials.util 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,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.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkVideoDomain -import java.text.SimpleDateFormat import java.util.Locale fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial = @@ -22,36 +22,110 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial = 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() + 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() + 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() + 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( 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 + 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 + title = attachment.title, + previewUrl = attachment.photo?.getMaxSize()?.url, + url = attachment.url, + urlFirstChar = attachment.url.replaceFirst("http://", "") + .replaceFirst("https://", "") + .take(1) + .uppercase() ) } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt index c8026d52..da955b90 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt @@ -13,7 +13,7 @@ data class ConversationsScreenState( val isPaginationExhausted: Boolean, val profileImageUrl: String?, val scrollIndex: Int, - val scrollOffset: Int + val scrollOffset: Int, ) { companion object { diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index b7e1a75f..22942bf4 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -137,7 +137,6 @@ fun ConversationsScreen( setScrollIndex: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {} ) { - val view = LocalView.current val currentTheme = LocalThemeConfig.current val maxLines by remember(currentTheme) { diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt index b2d331e4..4bd67313 100644 --- a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.conversations.presentation import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -79,8 +80,6 @@ fun CreateChatRoute( onChatCreated: (Int) -> Unit, viewModel: CreateChatViewModel ) { - val context = LocalContext.current - val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() @@ -148,20 +147,24 @@ fun CreateChatScreen( val hazeState = LocalHazeState.current - val toolbarColorAlpha by animateFloatAsState( - targetValue = if (!listState.canScrollBackward) 1f else 0f, + val topBarContainerColorAlpha by animateFloatAsState( + targetValue = if (!currentTheme.enableBlur || !listState.canScrollBackward) 1f else 0f, label = "toolbarColorAlpha", - animationSpec = tween(durationMillis = 50) + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) ) - val toolbarContainerColor by animateColorAsState( + val topBarContainerColor by animateColorAsState( targetValue = - if (currentTheme.enableBlur || !listState.canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + if (currentTheme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), label = "toolbarColorAlpha", - animationSpec = tween(durationMillis = 50) + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) ) Scaffold( @@ -171,11 +174,7 @@ fun CreateChatScreen( Column( modifier = Modifier .fillMaxWidth() - .background( - toolbarContainerColor.copy( - alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f - ) - ) + .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) .then( if (currentTheme.enableBlur) { Modifier.hazeEffect( @@ -205,11 +204,7 @@ fun CreateChatScreen( style = MaterialTheme.typography.headlineSmall ) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = toolbarContainerColor.copy( - alpha = 0f - ) - ), + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), modifier = Modifier.fillMaxWidth(), ) diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt index 12a4c515..3212389c 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt @@ -24,14 +24,13 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.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.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList - +import kotlinx.coroutines.launch import dev.meloda.fast.ui.R as UiR @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -60,10 +59,7 @@ fun FriendsRoute( onPhotoClicked: (url: String) -> Unit, onMessageClicked: (userId: Int) -> Unit, ) { - var selectedTabIndex by rememberSaveable { - mutableIntStateOf(0) - } - + val scope = rememberCoroutineScope() val currentTheme = LocalThemeConfig.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 showOrderDialog by remember { mutableStateOf(false) } - val orderItems = remember { - mapOf( - "hints" to "Priority", - "name" to "Name", - "random" to "Random", - "mobile" to "Mobile", - "smart" to "Smart" + val orderPriority = stringResource(UiR.string.friends_order_priority) + val orderName = stringResource(UiR.string.friends_order_name) + val orderRandom = stringResource(UiR.string.friends_order_random) + val orderMobile = stringResource(UiR.string.friends_order_mobile) + val orderSmart = stringResource(UiR.string.friends_order_smart) + + val orderTitleItems = remember { + ImmutableList.of( + orderPriority, + orderName, + orderRandom, + orderMobile, + orderSmart ) } + val orderItems = remember { + listOf("hints", "name", "random", "mobile", "smart") + } + var selectedIndex by remember { mutableIntStateOf(0) } @@ -130,17 +142,16 @@ fun FriendsRoute( onDismissRequest = { showOrderDialog = false }, confirmText = stringResource(R.string.ok), confirmAction = { - orderType = - orderItems.keys.toCollection(mutableListOf())[selectedIndex] + orderType = orderItems[selectedIndex] }, cancelText = stringResource(R.string.cancel), selectionType = SelectionType.Single, - items = ImmutableList.copyOf(orderItems.values), + items = orderTitleItems, preSelectedItems = ImmutableList.of(selectedIndex), onItemClick = { selectedIndex = it }, - title = "Order type", + title = stringResource(UiR.string.friends_order_by_title), actionInvokeDismiss = ActionInvokeDismiss.Always ) } @@ -199,8 +210,8 @@ fun FriendsRoute( Tab( selected = index == selectedTabIndex, onClick = { - if (selectedTabIndex != index) { - selectedTabIndex = index + scope.launch { + pagerState.animateScrollToPage(index) } }, text = { @@ -214,21 +225,6 @@ fun FriendsRoute( } } ) { padding -> - val pagerState = rememberPagerState( - initialPage = selectedTabIndex - ) { - tabItems.size - } - - LaunchedEffect(selectedTabIndex) { - pagerState.animateScrollToPage(selectedTabIndex) - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect { selectedTabIndex = it } - } - HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index c7c67c84..5fd03358 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -229,8 +229,7 @@ fun MessagesHistoryScreen( .fillMaxWidth(), title = { Row( - modifier = Modifier - .weight(1f), + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { if (selectedMessages.isEmpty()) { @@ -332,6 +331,9 @@ fun MessagesHistoryScreen( ) } } else { + if (screenState.isLoading) { + return@TopAppBar + } IconButton( onClick = { dropDownMenuExpanded = true } ) { @@ -362,7 +364,13 @@ fun MessagesHistoryScreen( ) }, text = { - Text(text = "Materials") + Text(text = stringResource(UiR.string.chat_materials_action_title)) + }, + leadingIcon = { + Icon( + painter = painterResource(UiR.drawable.ic_multimedia), + contentDescription = null + ) } ) DropdownMenuItem( @@ -371,7 +379,7 @@ fun MessagesHistoryScreen( dropDownMenuExpanded = false }, text = { - Text(text = "Refresh") + Text(text = stringResource(UiR.string.action_refresh)) }, leadingIcon = { Icon(