Приложение

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

View File

@@ -0,0 +1,151 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
@Composable
fun AppAudioPlayer(
url: String,
isVoiceMessage: Boolean = false,
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
prepare()
}
}
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
// Обновление прогресса
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
delay(100)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (isPlaying) exoPlayer.pause() else exoPlayer.play()
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = contentColor
)
}
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
Slider(
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
onValueChange = {
val newPos = (it * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
},
modifier = Modifier.height(24.dp),
colors = SliderDefaults.colors(
thumbColor = contentColor,
activeTrackColor = contentColor,
inactiveTrackColor = contentColor.copy(alpha = 0.3f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatDuration(currentPosition),
fontSize = 10.sp,
color = contentColor.copy(alpha = 0.7f)
)
Text(
text = formatDuration(duration),
fontSize = 10.sp,
color = contentColor.copy(alpha = 0.7f)
)
}
}
if (isVoiceMessage) {
TextButton(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
}
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(40.dp)
) {
Text(
text = "${playbackSpeed}x",
fontSize = 12.sp,
color = contentColor,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
private fun formatDuration(durationMs: Long): String {
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}

View File

@@ -0,0 +1,50 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
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.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.presentation.theme.SoftSquareShape
@Composable
fun AppAvatar(
url: String?,
name: String,
size: Dp = 48.dp,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(SoftSquareShape) // Тот самый "мягкий квадрат"
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
if (url != null) {
AsyncImage(
model = url,
contentDescription = name,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop
)
} else {
// Заглушка, если нет аватара (первая буква имени)
Text(
text = name.take(1).uppercase(),
color = MaterialTheme.colorScheme.primary,
fontSize = (size.value * 0.4).sp,
style = MaterialTheme.typography.titleMedium
)
}
}
}

View File

@@ -0,0 +1,61 @@
package core.presentation.components
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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 androidx.media3.ui.AspectRatioFrameLayout
@OptIn(UnstableApi::class)
@Composable
fun AppVideoPlayer(
url: String,
modifier: Modifier = Modifier,
useController: Boolean = true,
autoPlay: Boolean = true,
onVideoFinished: () -> Unit = {}
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
prepare()
playWhenReady = autoPlay
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
})
}
}
// Очистка ресурсов при выходе
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
factory = {
PlayerView(it).apply {
player = exoPlayer
this.useController = useController
// Для сторис используем заполнение экрана
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
},
modifier = modifier
)
}

View File

@@ -0,0 +1,155 @@
package core.presentation.components
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.ChatBubble
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.knot.messager.R
@Composable
fun GlassNavigationBar(
currentRoute: String,
onNavigate: (String) -> Unit,
userAvatarUrl: String? = null,
username: String = ""
) {
// Контейнер с эффектом стекла
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
.height(72.dp)
.clip(RoundedCornerShape(32.dp))
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
)
)
)
.border(
1.dp,
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.2f),
Color.Transparent
)
),
RoundedCornerShape(32.dp)
),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
NavItem(
icon = Icons.Default.ChatBubble,
label = stringResource(R.string.chats),
isSelected = currentRoute == "chat_list",
onClick = { onNavigate("chat_list") }
)
NavItem(
icon = Icons.Default.People,
label = stringResource(R.string.contacts_tab),
isSelected = currentRoute == "contacts",
onClick = { onNavigate("contacts") }
)
NavItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
isSelected = currentRoute == "settings",
onClick = { onNavigate("settings") }
)
// Профиль с аватаром
ProfileNavItem(
avatarUrl = userAvatarUrl,
username = username,
isSelected = currentRoute.startsWith("profile"),
onClick = { onNavigate("profile") }
)
}
}
}
@Composable
private fun NavItem(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent
Column(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = label, tint = contentColor, modifier = Modifier.size(24.dp))
Text(text = label, color = contentColor, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp))
}
}
@Composable
private fun ProfileNavItem(
avatarUrl: String?,
username: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
Column(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(28.dp)
.border(2.dp, borderColor, CircleShape)
.padding(2.dp)
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 24.dp
)
}
Text(
text = stringResource(R.string.profile_tab),
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp)
)
}
}

View File

@@ -0,0 +1,102 @@
package core.presentation.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Text(
text = stringResource(R.string.server_connection),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text(stringResource(R.string.api_base_url)) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("https://example.com/api/") }
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.server_features),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
FeatureStatusItem(stringResource(R.string.stories), state.config.features.stories)
FeatureStatusItem(stringResource(R.string.polls), state.config.features.polls)
FeatureStatusItem(stringResource(R.string.calls), state.config.features.calls)
FeatureStatusItem(stringResource(R.string.groups), state.config.features.groups)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.limits),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text("${stringResource(R.string.max_file_size)}: ${state.config.limits.maxFileSize / (1024 * 1024)} MB")
Text("${stringResource(R.string.max_group_members)}: ${state.config.limits.maxGroupMembers}")
}
}
}
@Composable
fun FeatureStatusItem(name: String, enabled: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name)
Text(
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}

View File

@@ -0,0 +1,41 @@
package core.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import core.domain.model.ServerConfigModel
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsState(
val baseUrl: String = "",
val config: ServerConfigModel = ServerConfigModel()
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val serverConfig: ServerConfig
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
_state.update {
it.copy(
baseUrl = serverConfig.getBaseUrl(),
config = serverConfig.getServerConfig()
)
}
}
fun saveBaseUrl(url: String) {
serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) }
}
}

View File

@@ -0,0 +1,41 @@
package core.presentation.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// Цвета из веб-версии (Dark Mode)
val DarkBackground = Color(0xFF0F0F10)
val SurfaceContainer = Color(0xFF161618)
val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
val PrimaryIndigo = Primary
val Indigo500 = Primary
val OnSurfaceVariant = Color(0xFF94A3B8)
private val DarkColors = darkColorScheme(
primary = Primary,
background = DarkBackground,
surface = SurfaceContainer,
onPrimary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onSurfaceVariant = OnSurfaceVariant
)
val SoftSquareShape = RoundedCornerShape(24.dp) // "Мягкий квадрат"
@Composable
fun ForkMessengerTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColors,
shapes = MaterialTheme.shapes.copy(
small = RoundedCornerShape(12.dp),
medium = SoftSquareShape,
large = RoundedCornerShape(32.dp)
),
content = content
)
}