This commit is contained in:
Халимов Рустам
2026-04-14 13:46:17 +03:00
parent 2d0bc0d75c
commit 8ce4bc714f
13 changed files with 560 additions and 10 deletions
Binary file not shown.
Binary file not shown.
+27 -7
View File
@@ -39,13 +39,13 @@ interface ChatApi {
// Klipy GIF API // Klipy GIF API
@GET("klipy/trending") @GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): List<KlipyGifDto> suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@GET("klipy/search") @GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): List<KlipyGifDto> suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
@GET("klipy/categories") @GET("klipy/categories")
suspend fun getGifCategories(): List<GifCategoryDto> suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}") @POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String) suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@@ -60,15 +60,27 @@ interface ChatApi {
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String) suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
} }
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto( data class KlipyGifDto(
val id: String, val id: String,
val images: GifImagesDto?, val images: GifImagesDto? = null,
val title: String? val files: Map<String, Map<String, GifImageSourceDto>>? = null,
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
val media_formats: Map<String, GifImageSourceDto>? = null,
val title: String? = null
) )
data class GifImagesDto( data class GifImagesDto(
val fixed_height: GifImageSourceDto?, val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
) )
data class GifImageSourceDto( data class GifImageSourceDto(
@@ -86,3 +98,11 @@ data class FileUploadResponse(
val filename: String, val filename: String,
val size: Long val size: Long
) )
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)
@@ -55,6 +55,18 @@ class ChatRepositoryImpl @Inject constructor(
val body = MultipartBody.Part.createFormData("file", file.name, requestFile) val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url return api.uploadFile(body).url
} }
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.getTrendingGifs(page).data.data
}
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.searchGifs(query, page).data.data
}
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
} }
// Mappers // Mappers
@@ -11,5 +11,8 @@ interface ChatRepository {
suspend fun sendTypingStatus(chatId: String) suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
suspend fun uploadMedia(file: java.io.File): String suspend fun uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
} }
@@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chats.presentation.components.EmojiPicker import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar import core.presentation.components.AppAvatar
import core.presentation.components.AppMediaLightbox import core.presentation.components.AppMediaLightbox
@@ -246,7 +246,30 @@ fun ChatDetailScreen(
} }
if (isEmojiPickerVisible) { if (isEmojiPickerVisible) {
EmojiPicker(onEmojiSelected = { textInput += it }) LaunchedEffect(Unit) {
viewModel.loadTrendingGifs()
}
MediaPicker(
trendingGifs = state.trendingGifs,
searchedGifs = state.searchedGifs,
recentGifs = state.recentGifs,
gifCategories = state.gifCategories,
isGifsLoading = state.isGifsLoading,
error = state.error,
onEmojiSelected = { textInput += it },
onGifSelected = { url ->
viewModel.sendGif(url)
isEmojiPickerVisible = false
},
onGifSearch = { query ->
viewModel.searchGifs(query)
}
)
LaunchedEffect(Unit) {
viewModel.loadTrendingGifs()
viewModel.loadGifCategories()
}
} }
} }
} }
@@ -17,6 +17,8 @@ import kotlinx.coroutines.launch
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import chats.data.remote.api.KlipyGifDto
data class ChatDetailState( data class ChatDetailState(
val messages: List<Message> = emptyList(), val messages: List<Message> = emptyList(),
val chatName: String? = null, val chatName: String? = null,
@@ -26,7 +28,12 @@ data class ChatDetailState(
val typingUser: String? = null, val typingUser: String? = null,
val error: String? = null, val error: String? = null,
val canCall: Boolean = true, val canCall: Boolean = true,
val maxFileSize: Long = 100 * 1024 * 1024 val maxFileSize: Long = 100 * 1024 * 1024,
val trendingGifs: List<KlipyGifDto> = emptyList(),
val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false
) )
@HiltViewModel @HiltViewModel
@@ -182,5 +189,78 @@ class ChatDetailViewModel @Inject constructor(
} }
} }
} }
fun loadTrendingGifs() {
if (_state.value.trendingGifs.isNotEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.getTrendingGifs(0)
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
}
}
}
fun loadGifCategories() {
if (_state.value.gifCategories.isNotEmpty()) return
viewModelScope.launch {
try {
val categories = repository.getGifCategories()
_state.update { it.copy(gifCategories = categories) }
} catch (e: Exception) {
// Ignore failure
}
}
}
private var searchJob: Job? = null
fun searchGifs(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
_state.update { it.copy(searchedGifs = emptyList()) }
return
}
searchJob = viewModelScope.launch {
delay(500)
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.searchGifs(query, 0)
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false) }
}
}
}
fun sendGif(url: String) {
val chatId = currentChatId ?: return
viewModelScope.launch {
try {
repository.sendMessage(chatId, url)
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
val selectedGif = allGifs.find { gif ->
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
gifUrl == url
}
selectedGif?.let { gif ->
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
} }
@@ -0,0 +1,412 @@
package chats.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
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.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
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.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import chats.data.remote.api.KlipyGifDto
import core.utils.CoilUtils
import kotlinx.coroutines.launch
enum class MediaTab { EMOJI, GIF }
@Composable
fun MediaPicker(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto> = emptyList(),
gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
isGifsLoading: Boolean,
error: String?,
onEmojiSelected: (String) -> Unit,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit,
modifier: Modifier = Modifier
) {
var activeTab by remember { mutableStateOf(MediaTab.EMOJI) }
// Вычисляем индексы для прыжков (учитываем заголовки)
val emojiCategories = remember {
listOf(
EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")),
EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")),
EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")),
EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")),
EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("", "🏀", "🏈", "", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")),
EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")),
EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")),
EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")),
EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩"))
)
}
val categoryIndices = remember(emojiCategories) {
var currentIdx = 0
emojiCategories.map { cat ->
val startIdx = currentIdx
currentIdx += 1 + cat.emojis.size
cat.icon to startIdx
}
}
Column(
modifier = modifier
.fillMaxWidth()
.height(450.dp)
.background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
) {
// Табы (Эмодзи / GIF)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp))
.padding(2.dp)
) {
MediaTabItem(
title = "ЭМОДЗИ",
isSelected = activeTab == MediaTab.EMOJI,
onClick = { activeTab = MediaTab.EMOJI },
modifier = Modifier.weight(1f)
)
MediaTabItem(
title = "GIF",
isSelected = activeTab == MediaTab.GIF,
onClick = { activeTab = MediaTab.GIF },
modifier = Modifier.weight(1f)
)
}
Crossfade(targetState = activeTab, label = "media_tab") { tab ->
when (tab) {
MediaTab.EMOJI -> EmojiContent(
emojiCategories = emojiCategories,
categoryIndices = categoryIndices,
onEmojiSelected = onEmojiSelected
)
MediaTab.GIF -> GifContent(
trendingGifs = trendingGifs,
searchedGifs = searchedGifs,
recentGifs = recentGifs,
gifCategories = gifCategories,
isGifsLoading = isGifsLoading,
error = error,
onGifSelected = onGifSelected,
onGifSearch = onGifSearch
)
}
}
}
}
@Composable
private fun MediaTabItem(
title: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent)
.clickable { onClick() }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = title,
color = if (isSelected) Color.White else Color.Gray,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun EmojiContent(
emojiCategories: List<EmojiCategory>,
categoryIndices: List<Pair<ImageVector, Int>>,
onEmojiSelected: (String) -> Unit
) {
val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// Категории
LazyRow(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categoryIndices) { (icon, index) ->
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier
.size(24.dp)
.clickable {
scope.launch {
scrollState.animateScrollToItem(index)
}
}
)
}
}
// Поиск
TextField(
value = "",
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
state = scrollState,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
) {
emojiCategories.forEach { category ->
// Заголовок категории
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) {
Text(
text = category.name,
color = Color.Gray,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(8.dp)
)
}
items(category.emojis) { emoji ->
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 28.sp)
}
}
}
}
}
}
data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List<String>)
@Composable
private fun GifContent(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto>,
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
isGifsLoading: Boolean,
error: String?,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
var subTab by remember { mutableStateOf("trending") }
val context = LocalContext.current
val imageLoader = CoilUtils.getGifImageLoader(context)
Column(modifier = Modifier.fillMaxSize()) {
TextField(
value = query,
onValueChange = {
query = it
if (it.isNotEmpty()) subTab = "search"
else if (subTab == "search") subTab = "trending"
onGifSearch(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск GIF...", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
// Под-вкладки GIF (Recent, Trending, Categories)
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Schedule,
contentDescription = null,
tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" }
)
Icon(
Icons.Rounded.TrendingUp,
contentDescription = null,
tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" }
)
Icon(
Icons.Rounded.GridView,
contentDescription = null,
tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" }
)
}
if (subTab == "categories") {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp)
) {
items(gifCategories) { category ->
Box(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.77f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2C2C2C))
.clickable {
query = category.query
subTab = "search"
onGifSearch(category.query)
}
) {
AsyncImage(
model = category.preview_url,
contentDescription = category.category,
modifier = Modifier.fillMaxSize().alpha(0.6f),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = category.category.uppercase(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp
)
}
}
}
}
} else {
val displayGifs = when (subTab) {
"recent" -> recentGifs
"search" -> searchedGifs
else -> trendingGifs
}
if (isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color(0xFF3B82F6))
}
} else if (!isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(error ?: "Ничего не найдено", color = Color.Gray)
if (error != null) {
Button(onClick = { onGifSearch(query) }) {
Text("Повторить")
}
}
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp)
) {
items(displayGifs) { gif ->
val url = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
val preview = gif.files?.get("sd")?.get("webp")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("webp")?.url
?: gif.media_formats?.get("tinygif")?.url
?: gif.images?.fixed_height_small?.url
?: url
if (url != null) {
AsyncImage(
model = preview ?: url,
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2C2C2C))
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF3C3C3C)),
contentAlignment = Alignment.Center
) {
Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp)
}
}
}
}
}
}
}
}