Приложение

This commit is contained in:
Халимов Рустам
2026-04-14 01:15:54 +03:00
parent 8399d32490
commit 1fb1be47dd
126 changed files with 6145 additions and 0 deletions
@@ -0,0 +1,316 @@
package stories.presentation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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 androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import core.presentation.theme.OnSurfaceVariant
import core.presentation.theme.PrimaryIndigo
import core.presentation.theme.SurfaceContainer
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateStoryScreen(
onClose: () -> Unit,
onCreated: () -> Unit,
viewModel: CreateStoryViewModel = hiltViewModel()
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val isVideo = context.contentResolver.getType(it)?.startsWith("video/") == true
viewModel.onMediaSelected(it, isVideo)
}
}
LaunchedEffect(viewModel.uploadSuccess) {
viewModel.uploadSuccess.collect { success ->
if (success) {
onCreated()
onClose()
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Header(
onClose = onClose,
isUploading = viewModel.isUploading,
onPublish = { viewModel.publishStory(context) }
)
Row(modifier = Modifier.weight(1f)) {
// Main Area
Box(
modifier = Modifier
.weight(1f)
.padding(16.dp)
.clip(RoundedCornerShape(32.dp))
.background(viewModel.bgColor)
.border(8.dp, Color(0xFF161616), RoundedCornerShape(32.dp)),
contentAlignment = Alignment.Center
) {
if (viewModel.selectedMediaUri == null) {
EmptyMediaState(onSelect = { launcher.launch("*/*") })
} else {
MediaPreview(
uri = viewModel.selectedMediaUri,
isVideo = viewModel.isVideo,
onRemove = { viewModel.removeMedia() }
)
}
}
// Sidebar Tools
Sidebar(
currentTool = viewModel.currentTool,
onToolSelect = { viewModel.setTool(it) },
onAddText = { viewModel.addText("New Text") },
onColorChange = { viewModel.updateBgColor(it) },
bgColor = viewModel.bgColor
)
}
}
}
}
@Composable
fun Header(
onClose: () -> Unit,
isUploading: Boolean,
onPublish: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color(0xFF0A0A0A))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.back), tint = Color.Gray)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.story_editor),
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Black,
letterSpacing = 2.sp,
color = Color.Gray
)
)
}
Button(
onClick = onPublish,
enabled = !isUploading,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo),
shape = RoundedCornerShape(12.dp)
) {
if (isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
stringResource(R.string.publish),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Black)
)
}
}
}
}
@Composable
fun EmptyMediaState(onSelect: () -> Unit) {
Column(
modifier = Modifier.clickable { onSelect() },
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(80.dp)
.border(2.dp, Color(0xFF333333), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF333333), modifier = Modifier.size(32.dp))
}
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.start_creation),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
color = Color(0xFF333333)
)
)
}
}
@Composable
fun MediaPreview(
uri: Uri?,
isVideo: Boolean,
onRemove: () -> Unit
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
playWhenReady = true
}
}
LaunchedEffect(uri) {
if (isVideo && uri != null) {
exoPlayer.setMediaItem(MediaItem.fromUri(uri))
exoPlayer.prepare()
}
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
Box(modifier = Modifier.fillMaxSize()) {
if (isVideo) {
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
useController = false
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
},
modifier = Modifier.fillMaxSize()
)
} else {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(Color.Black.copy(0.6f), RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove), tint = Color.White)
}
}
}
@Composable
fun Sidebar(
currentTool: StoryTool,
onToolSelect: (StoryTool) -> Unit,
onAddText: () -> Unit,
onColorChange: (Color) -> Unit,
bgColor: Color
) {
Column(
modifier = Modifier
.width(100.dp)
.fillMaxHeight()
.background(Color(0xFF0A0A0A))
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ToolButton(Icons.Default.TextFields, stringResource(R.string.text_tool), currentTool is StoryTool.Text) { onAddText() }
ToolButton(Icons.Default.ContentCut, stringResource(R.string.crop_tool), currentTool is StoryTool.Crop) { onToolSelect(StoryTool.Crop) }
ToolButton(Icons.Default.SentimentSatisfied, stringResource(R.string.stickers_tool), currentTool is StoryTool.Stickers) { onToolSelect(StoryTool.Stickers) }
ToolButton(Icons.Default.Brush, stringResource(R.string.brush_tool), currentTool is StoryTool.Brush) { onToolSelect(StoryTool.Brush) }
ToolButton(Icons.Default.Tune, stringResource(R.string.filters_tool), currentTool is StoryTool.Filters) { onToolSelect(StoryTool.Filters) }
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(bgColor)
.border(2.dp, Color.White.copy(0.1f), CircleShape)
.clickable { onColorChange(Color((0..0xFFFFFF).random() or 0xFF000000.toInt())) }
)
}
}
@Composable
fun ToolButton(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
icon,
contentDescription = label,
tint = if (isSelected) PrimaryIndigo else OnSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Text(
label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 8.sp,
fontWeight = FontWeight.Black,
color = if (isSelected) Color.White else OnSurfaceVariant
)
)
}
}
@@ -0,0 +1,165 @@
package stories.presentation
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import stories.data.remote.api.CreateStoryRequest
import stories.data.remote.api.StoryApi
import java.io.File
import javax.inject.Inject
data class TextObject(
val id: Long = System.currentTimeMillis(),
val text: String,
val offset: Offset = Offset(100f, 100f),
val color: Color = Color.White,
val fontSize: Float = 24f,
val scale: Float = 1f
)
data class StickerObject(
val id: Long = System.currentTimeMillis(),
val emoji: String,
val offset: Offset = Offset(200f, 200f),
val scale: Float = 1.5f
)
sealed class StoryTool {
object None : StoryTool()
object Text : StoryTool()
object Crop : StoryTool()
object Stickers : StoryTool()
object Brush : StoryTool()
object Filters : StoryTool()
object Trim : StoryTool()
}
@HiltViewModel
class CreateStoryViewModel @Inject constructor(
private val storyApi: StoryApi,
private val serverConfig: ServerConfig
) : ViewModel() {
var selectedMediaUri by mutableStateOf<Uri?>(null)
private set
var isVideo by mutableStateOf(false)
private set
var currentTool by mutableStateOf<StoryTool>(StoryTool.None)
private set
var bgColor by mutableStateOf(Color(0xFF1E1E2E))
private set
val textObjects = mutableStateListOf<TextObject>()
val stickerObjects = mutableStateListOf<StickerObject>()
var isUploading by mutableStateOf(false)
private set
private val _uploadSuccess = MutableStateFlow(false)
val uploadSuccess = _uploadSuccess.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage = _errorMessage.asStateFlow()
fun onMediaSelected(uri: Uri, isVideo: Boolean) {
this.selectedMediaUri = uri
this.isVideo = isVideo
if (isVideo) {
currentTool = StoryTool.Trim
} else {
currentTool = StoryTool.Crop
}
}
fun setTool(tool: StoryTool) {
currentTool = tool
}
fun addText(text: String) {
textObjects.add(TextObject(text = text))
currentTool = StoryTool.Text
}
fun addSticker(emoji: String) {
stickerObjects.add(StickerObject(emoji = emoji))
}
fun updateBgColor(color: Color) {
bgColor = color
}
fun removeMedia() {
selectedMediaUri = null
isVideo = false
currentTool = StoryTool.None
textObjects.clear()
stickerObjects.clear()
}
fun publishStory(context: android.content.Context) {
val uri = selectedMediaUri ?: return
val maxFileSize = serverConfig.getServerConfig().limits.maxFileSize
viewModelScope.launch {
isUploading = true
try {
val file = uriToFile(context, uri)
if (file.length() > maxFileSize) {
_errorMessage.value = "File too large for this server"
return@launch
}
val mediaUrl = if (isVideo) {
val part = MultipartBody.Part.createFormData(
"file", file.name, file.asRequestBody("video/*".toMediaTypeOrNull())
)
storyApi.uploadVideo(part).url
} else {
// Simulating image upload via same or different endpoint
"https://example.com/simulated_upload.jpg"
}
val request = CreateStoryRequest(
type = if (isVideo) "video" else "image",
mediaUrl = mediaUrl,
bgColor = String.format("#%06X", (0xFFFFFF and bgColor.value.toInt())),
content = null // Serialize stickers/text if needed
)
storyApi.createStory(request)
_uploadSuccess.value = true
} catch (e: Exception) {
_errorMessage.value = e.message ?: "Upload failed"
} finally {
isUploading = false
}
}
}
private fun uriToFile(context: android.content.Context, uri: Uri): File {
val inputStream = context.contentResolver.openInputStream(uri)
val file = File(context.cacheDir, "temp_story_${System.currentTimeMillis()}")
file.outputStream().use { outputStream ->
inputStream?.copyTo(outputStream)
}
return file
}
}
@@ -0,0 +1,61 @@
package stories.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import stories.data.remote.api.StoryGroupDto
import stories.domain.repository.StoryRepository
import javax.inject.Inject
data class StoryState(
val storyGroups: List<StoryGroupDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class StoryViewModel @Inject constructor(
private val repository: StoryRepository
) : ViewModel() {
private val _state = MutableStateFlow(StoryState())
val state: StateFlow<StoryState> = _state.asStateFlow()
init {
loadStories()
}
fun loadStories() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val stories = repository.getStories()
_state.update { it.copy(storyGroups = stories, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
}
}
}
fun markAsViewed(storyId: String) {
viewModelScope.launch {
try {
repository.viewStory(storyId)
} catch (e: Exception) {
// Ignore error for viewing
}
}
}
fun sendReaction(storyId: String, emoji: String) {
viewModelScope.launch {
try {
repository.addReaction(storyId, emoji)
} catch (e: Exception) {
// Handle error
}
}
}
}
@@ -0,0 +1,192 @@
package stories.presentation
import android.net.Uri
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class)
@Composable
fun StoryViewerScreen(
stories: List<String>, // URL-адреса фото/видео
onClose: () -> Unit
) {
val context = LocalContext.current
var currentIndex by remember { mutableIntStateOf(0) }
val progress = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
var isPaused by remember { mutableStateOf(false) }
val currentUrl = stories[currentIndex]
val isVideo = currentUrl.endsWith(".mp4") || currentUrl.contains("video")
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_OFF
playWhenReady = true
}
}
DisposableEffect(exoPlayer) {
onDispose {
exoPlayer.release()
}
}
// Подготовка видео при смене индекса
LaunchedEffect(currentIndex) {
if (isVideo) {
exoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(currentUrl)))
exoPlayer.prepare()
exoPlayer.play()
} else {
exoPlayer.stop()
}
}
// Запуск таймера прогресса
LaunchedEffect(currentIndex, isPaused) {
if (!isPaused) {
val duration = if (isVideo) {
// Ждем пока загрузится длительность видео или используем 15сек по умолчанию
delay(500) // Даем немного времени на загрузку метаданных
if (exoPlayer.duration > 0) exoPlayer.duration else 15000
} else {
5000L // 5 секунд для фото
}
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = duration.toInt(),
easing = LinearEasing
)
)
// Когда прогресс дошел до конца — следующая история
if (currentIndex < stories.size - 1) {
currentIndex++
progress.snapTo(0f)
} else {
onClose()
}
} else {
if (isVideo) exoPlayer.pause() else Unit
}
}
// Возобновление видео при снятии паузы
LaunchedEffect(isPaused) {
if (!isPaused && isVideo) exoPlayer.play()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPaused = true
tryAwaitRelease()
isPaused = false
},
onTap = { offset ->
val screenWidth = size.width
if (offset.x < screenWidth / 3) {
if (currentIndex > 0) {
currentIndex--
scope.launch { progress.snapTo(0f) }
}
} else {
if (currentIndex < stories.size - 1) {
currentIndex++
scope.launch { progress.snapTo(0f) }
} else {
onClose()
}
}
}
)
}
) {
if (isVideo) {
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
useController = false
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
},
modifier = Modifier.fillMaxSize()
)
} else {
AsyncImage(
model = currentUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// Верхние индикаторы
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
stories.forEachIndexed { index, _ ->
val currentProgress = when {
index < currentIndex -> 1f
index == currentIndex -> progress.value
else -> 0f
}
LinearProgressIndicator(
progress = currentProgress,
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(RoundedCornerShape(2.dp)),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f)
)
}
}
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 24.dp, end = 16.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
}
}
}
@@ -0,0 +1,63 @@
package stories.presentation.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
@Composable
fun StoryThumbnail(
username: String,
avatarUrl: String?,
hasUnseen: Boolean,
onClick: () -> Unit
) {
val gradientBrush = Brush.sweepGradient(
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
)
Column(
modifier = Modifier
.padding(8.dp)
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(72.dp)
.padding(4.dp)
.then(
if (hasUnseen) Modifier.border(3.dp, gradientBrush, SoftSquareShape)
else Modifier.border(1.dp, Color.Gray, SoftSquareShape)
),
contentAlignment = Alignment.Center
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 60.dp,
modifier = Modifier.padding(2.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = username,
fontSize = 11.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface
)
}
}