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

View File

@@ -39,13 +39,13 @@ interface ChatApi {
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): List<KlipyGifDto>
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@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")
suspend fun getGifCategories(): List<GifCategoryDto>
suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}")
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)
}
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto(
val id: String,
val images: GifImagesDto?,
val title: String?
val images: GifImagesDto? = null,
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(
val fixed_height: GifImageSourceDto?,
val original: GifImageSourceDto?
val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
)
data class GifImageSourceDto(
@@ -86,3 +98,11 @@ data class FileUploadResponse(
val filename: String,
val size: Long
)
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)

View File

@@ -55,6 +55,18 @@ class ChatRepositoryImpl @Inject constructor(
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
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

View File

@@ -11,5 +11,8 @@ interface ChatRepository {
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: 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>
}

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chats.presentation.components.EmojiPicker
import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar
import core.presentation.components.AppMediaLightbox
@@ -246,7 +246,30 @@ fun ChatDetailScreen(
}
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()
}
}
}
}

View File

@@ -17,6 +17,8 @@ import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import chats.data.remote.api.KlipyGifDto
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val chatName: String? = null,
@@ -26,7 +28,12 @@ data class ChatDetailState(
val typingUser: String? = null,
val error: String? = null,
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
@@ -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) }
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}
}
}