Приложение

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,208 @@
package profiles.presentation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
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.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import ru.knot.messager.R
import android.app.Activity
import android.content.Intent
import com.yalantis.ucrop.UCrop
import core.utils.ImageCropper
import java.io.File
import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditProfileScreen(
currentDisplayName: String?,
currentUsername: String,
currentBio: String?,
currentAvatarUrl: String?,
onSave: (displayName: String, bio: String, avatarUri: Uri?) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
var displayName by remember { mutableStateOf(currentDisplayName ?: "") }
var bio by remember { mutableStateOf(currentBio ?: "") }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
// Лаунчер для результата кропа
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
selectedImageUri = UCrop.getOutput(result.data!!)
}
}
)
// Лаунчер для выбора фото из галереи
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
if (uri != null) {
// После выбора фото запускаем UCrop
val destinationUri = Uri.fromFile(File(context.cacheDir, "${UUID.randomUUID()}.jpg"))
val cropIntent = ImageCropper.getCropIntent(context, uri, destinationUri).getIntent(context)
cropLauncher.launch(cropIntent)
}
}
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.edit_profile)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { onSave(displayName, bio, selectedImageUri) }) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save), tint = MaterialTheme.colorScheme.primary)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Смена фото
Box(
modifier = Modifier
.size(120.dp)
.clip(SoftSquareShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
contentAlignment = Alignment.Center
) {
if (selectedImageUri != null) {
AsyncImage(
model = selectedImageUri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
AppAvatar(
url = currentAvatarUrl,
name = currentUsername,
size = 120.dp
)
}
// Overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = stringResource(R.string.change_photo),
tint = Color.White
)
}
}
TextButton(
onClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.padding(top = 8.dp)
) {
Text(stringResource(R.string.change_photo))
}
Spacer(modifier = Modifier.height(32.dp))
// Поля ввода
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text(stringResource(R.string.display_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = SoftSquareShape
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = "@$currentUsername",
onValueChange = {},
label = { Text(stringResource(R.string.username_label)) },
modifier = Modifier.fillMaxWidth(),
enabled = false, // Обычно username меняется отдельно или вообще не меняется
shape = SoftSquareShape,
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = MaterialTheme.colorScheme.outlineVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text(stringResource(R.string.bio)) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 100.dp),
shape = SoftSquareShape,
maxLines = 5
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { onSave(displayName, bio, selectedImageUri) },
modifier = Modifier.fillMaxWidth(),
shape = SoftSquareShape
) {
Text(stringResource(R.string.save))
}
}
}
}

View File

@@ -0,0 +1,304 @@
package profiles.presentation
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.Color
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import kotlinx.coroutines.launch
import profiles.presentation.viewmodel.ProfileViewModel
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(
profileId: String? = null, // null for own profile
viewModel: ProfileViewModel = hiltViewModel(),
onEditProfile: () -> Unit = {},
onSendMessage: (String) -> Unit = {},
onCall: (String) -> Unit = {},
onBack: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(profileId) {
viewModel.loadProfile(profileId)
}
val profile = state.profile
val isOwnProfile = profileId == null
val mediaTabs = listOf(
stringResource(R.string.media),
"GIF",
"Файлы",
"Ссылки"
)
val pagerState = rememberPagerState(pageCount = { mediaTabs.size })
val coroutineScope = rememberCoroutineScope()
if (state.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
if (isOwnProfile) {
IconButton(onClick = onEditProfile) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit_profile))
}
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
item {
ProfileHeader(
username = profile?.username ?: "",
displayName = profile?.displayName,
avatarUrl = profile?.avatarUrl,
bio = profile?.bio,
isOwnProfile = isOwnProfile,
isCallsEnabled = true,
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
onCall = { profile?.id?.let { id -> onCall(id) } }
)
}
stickyHeader {
Surface(modifier = Modifier.fillMaxWidth()) {
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
divider = {}
) {
mediaTabs.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(title) }
)
}
}
}
}
// Using item with fillParentMaxSize for Pager inside LazyColumn
// is tricky, but here we render content directly in LazyColumn
// to maintain scrolling, or use a fixed height.
// For gestures, we implement HorizontalPager for the content:
}
// Alternative: Use HorizontalPager for the whole content area below header
// But to keep header scrolling, we need nested scroll or a different approach.
// Let's implement HorizontalPager for media content items specifically.
}
}
@Composable
fun ProfileHeader(
username: String,
displayName: String?,
avatarUrl: String?,
bio: String?,
isOwnProfile: Boolean,
isCallsEnabled: Boolean,
onSendMessage: () -> Unit,
onCall: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(contentAlignment = Alignment.BottomEnd) {
AppAvatar(
url = avatarUrl,
name = username,
size = 120.dp
)
if (isOwnProfile) {
FilledIconButton(
onClick = { /* Выбор фото */ },
modifier = Modifier.size(32.dp).offset(x = 4.dp, y = 4.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Icon(Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(16.dp))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = displayName ?: username, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(text = "@$username", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (!isOwnProfile) {
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionCircleButton(Icons.Default.Message, stringResource(R.string.message), onSendMessage)
if (isCallsEnabled) {
ActionCircleButton(Icons.Default.Call, stringResource(R.string.call), onCall)
}
ActionCircleButton(Icons.Default.Notifications, stringResource(R.string.notifications), {})
}
}
if (bio != null) {
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(text = bio, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
fun LazyListScope.renderMediaContent(tabIndex: Int) {
// В реальном приложении данные приходят из ViewModel
// Группировка по месяцам
val months = listOf("Октябрь 2023", "Сентябрь 2023")
months.forEach { month ->
item {
Text(
text = month,
modifier = Modifier.padding(16.dp, 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
when (tabIndex) {
0 -> renderMediaGrid() // Медиа (сетка 3x3)
1 -> renderGifs() // GIF
2 -> renderFiles() // Файлы
3 -> renderLinks() // Ссылки
}
}
}
// Вспомогательные функции для рендеринга контента...
fun LazyListScope.renderMediaGrid() {
item {
// Упрощенная сетка внутри LazyColumn
Row(modifier = Modifier.padding(horizontal = 16.dp)) {
repeat(3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.padding(2.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Gray.copy(alpha = 0.2f))
)
}
}
}
}
fun LazyListScope.renderFiles() {
items(3) {
ListItem(
headlineContent = { Text("Document.pdf") },
supportingContent = { Text("2.4 MB • 12.10.23") },
leadingContent = { Icon(Icons.Default.Description, contentDescription = null) }
)
}
}
fun LazyListScope.renderLinks() {
items(2) {
ListItem(
headlineContent = { Text("https://github.com/forkmessager") },
supportingContent = { Text("GitHub - ForkMessager Project") },
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
)
}
}
fun LazyListScope.renderGifs() {
item {
Row(modifier = Modifier.padding(horizontal = 16.dp)) {
repeat(2) {
Box(
modifier = Modifier
.weight(1f)
.height(120.dp)
.padding(2.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Gray.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Text("GIF", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
@Composable
fun ActionCircleButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit,
color: Color = MaterialTheme.colorScheme.primary
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = onClick,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color.copy(alpha = 0.1f))
) {
Icon(icon, contentDescription = label, tint = color)
}
Spacer(modifier = Modifier.height(4.dp))
Text(text = label, style = MaterialTheme.typography.labelSmall)
}
}

View File

@@ -0,0 +1,74 @@
package profiles.presentation.viewmodel
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import profiles.data.remote.dto.ProfileDto
import profiles.domain.repository.ProfileRepository
import javax.inject.Inject
data class ProfileState(
val profile: ProfileDto? = null,
val isLoading: Boolean = false,
val error: String? = null,
val isSaving: Boolean = false
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ProfileRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow()
fun loadProfile(id: String? = null) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
val result = if (id == null) {
repository.getMyProfile()
} else {
repository.getProfile(id)
}
result.onSuccess { profile ->
_state.value = _state.value.copy(profile = profile, isLoading = false)
}.onFailure { error ->
_state.value = _state.value.copy(error = error.message, isLoading = false)
}
}
}
fun updateProfile(displayName: String?, bio: String?, avatarUri: Uri? = null) {
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
// 1. Обновляем аватар, если он выбран
avatarUri?.let {
repository.uploadAvatar(it)
}
// 2. Обновляем текстовые данные
repository.updateProfile(displayName, bio)
.onSuccess { updatedProfile ->
_state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
}
.onFailure { error ->
_state.value = _state.value.copy(error = error.message, isSaving = false)
}
}
}
fun removeAvatar() {
viewModelScope.launch {
repository.removeAvatar().onSuccess { updatedProfile ->
_state.value = _state.value.copy(profile = updatedProfile)
}
}
}
}