Профиль, редактирование без аватара

This commit is contained in:
Халимов Рустам
2026-04-16 15:41:22 +03:00
parent 8409c51842
commit cea3f4d669
45 changed files with 1063 additions and 178 deletions

View File

@@ -9,7 +9,7 @@ interface ProfileApi {
@GET("profiles/{id}")
suspend fun getProfile(@Path("id") id: String): ProfileDto
@GET("profiles/me")
@GET("auth/me")
suspend fun getMyProfile(): ProfileDto
@PUT("profiles/profile")

View File

@@ -1,16 +1,29 @@
package profiles.data.remote.dto
data class ProfileDto(
@com.google.gson.annotations.SerializedName("id", alternate = ["userId"])
val id: String,
@com.google.gson.annotations.SerializedName("username", alternate = ["userName"])
val username: String,
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
@com.google.gson.annotations.SerializedName("avatarUrl", alternate = ["avatar"])
val avatarUrl: String?,
@com.google.gson.annotations.SerializedName("bio", alternate = ["about"])
val bio: String?,
@com.google.gson.annotations.SerializedName("isOnline")
val isOnline: Boolean = false,
val lastSeen: String? = null
@com.google.gson.annotations.SerializedName("lastSeen")
val lastSeen: String? = null,
@com.google.gson.annotations.SerializedName("birthday")
val birthday: String? = null
)
data class UpdateProfileRequest(
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
val bio: String?
@com.google.gson.annotations.SerializedName("bio")
val bio: String?,
@com.google.gson.annotations.SerializedName("birthday")
val birthday: String? = null
)

View File

@@ -15,30 +15,40 @@ import javax.inject.Inject
class ProfileRepositoryImpl @Inject constructor(
private val api: ProfileApi,
private val context: Context
private val context: Context,
private val serverConfig: core.network.ServerConfig
) : ProfileRepository {
private fun ProfileDto.fixAvatarUrl(): ProfileDto {
if (avatarUrl == null || avatarUrl.startsWith("http")) return this
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return copy(avatarUrl = baseUrl + avatarUrl)
}
override suspend fun getProfile(id: String): Result<ProfileDto> = runCatching {
api.getProfile(id)
api.getProfile(id).fixAvatarUrl()
}
override suspend fun getMyProfile(): Result<ProfileDto> = runCatching {
api.getMyProfile()
// Сначала получаем ID пользователя из /auth/me
val me = api.getMyProfile()
// Затем загружаем полный профиль из /profiles/{id}
api.getProfile(me.id).fixAvatarUrl()
}
override suspend fun updateProfile(displayName: String?, bio: String?): Result<ProfileDto> = runCatching {
api.updateProfile(UpdateProfileRequest(displayName, bio))
override suspend fun updateProfile(displayName: String?, bio: String?, birthday: String?): Result<ProfileDto> = runCatching {
api.updateProfile(UpdateProfileRequest(displayName, bio, birthday)).fixAvatarUrl()
}
override suspend fun uploadAvatar(uri: Uri): Result<ProfileDto> = runCatching {
val file = uriToFile(uri)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("avatar", file.name, requestFile)
api.uploadAvatar(body)
api.uploadAvatar(body).fixAvatarUrl()
}
override suspend fun removeAvatar(): Result<ProfileDto> = runCatching {
api.removeAvatar()
api.removeAvatar().fixAvatarUrl()
}
private fun uriToFile(uri: Uri): File {

View File

@@ -23,7 +23,11 @@ object ProfileModule {
@Provides
@Singleton
fun provideProfileRepository(api: ProfileApi, context: Context): ProfileRepository {
return ProfileRepositoryImpl(api, context)
fun provideProfileRepository(
api: ProfileApi,
context: Context,
serverConfig: core.network.ServerConfig
): ProfileRepository {
return ProfileRepositoryImpl(api, context, serverConfig)
}
}

View File

@@ -6,7 +6,7 @@ import profiles.data.remote.dto.ProfileDto
interface ProfileRepository {
suspend fun getProfile(id: String): Result<ProfileDto>
suspend fun getMyProfile(): Result<ProfileDto>
suspend fun updateProfile(displayName: String?, bio: String?): Result<ProfileDto>
suspend fun updateProfile(displayName: String?, bio: String?, birthday: String? = null): Result<ProfileDto>
suspend fun uploadAvatar(uri: Uri): Result<ProfileDto>
suspend fun removeAvatar(): Result<ProfileDto>
}

View File

@@ -12,8 +12,10 @@ 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.CalendarToday
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -30,6 +32,10 @@ import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import ru.knot.messager.R
import android.app.DatePickerDialog
import java.text.SimpleDateFormat
import java.util.*
import android.app.Activity
import android.content.Intent
import com.yalantis.ucrop.UCrop
@@ -43,14 +49,62 @@ fun EditProfileScreen(
currentDisplayName: String?,
currentUsername: String,
currentBio: String?,
currentBirthday: String?,
currentAvatarUrl: String?,
onSave: (displayName: String, bio: String, avatarUri: Uri?) -> Unit,
isSaving: Boolean = false,
error: String? = null,
onSave: (displayName: String, bio: String, birthday: String?, avatarUri: Uri?) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
var displayName by remember { mutableStateOf(currentDisplayName ?: "") }
var bio by remember { mutableStateOf(currentBio ?: "") }
var displayName by remember(currentDisplayName) { mutableStateOf(currentDisplayName ?: "") }
var bio by remember(currentBio) { mutableStateOf(currentBio ?: "") }
var birthday by remember(currentBirthday) { mutableStateOf(currentBirthday ?: "") }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
var showDatePicker by remember { mutableStateOf(false) }
// Парсим текущую дату для инициализации DatePicker и отображения
val calendar = Calendar.getInstance()
var formattedBirthday by remember { mutableStateOf("") }
if (birthday.isNotEmpty()) {
try {
// Пробуем распарсить ISO8601 формат (yyyy-MM-dd или yyyy-MM-ddTHH:mm:ss)
val format = when {
birthday.contains("T") -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
birthday.length == 10 -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
else -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
}
format.parse(birthday)?.let {
calendar.time = it
// Форматируем для отображения: ДД.ММ.ГГГГ
formattedBirthday = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(it)
}
} catch (e: Exception) {
formattedBirthday = birthday
}
}
// DatePickerDialog
if (showDatePicker) {
DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
val selectedCalendar = Calendar.getInstance()
selectedCalendar.set(year, month, dayOfMonth)
// Используем ISO8601 формат для отправки на сервер
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
birthday = format.format(selectedCalendar.time)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).apply {
datePicker.maxDate = System.currentTimeMillis()
show()
}
showDatePicker = false
}
// Лаунчер для результата кропа
val cropLauncher = rememberLauncherForActivityResult(
@@ -78,17 +132,28 @@ fun EditProfileScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.edit_profile)) },
title = { Text(stringResource(R.string.edit_profile), fontWeight = FontWeight.Bold) },
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)
TextButton(
onClick = { onSave(displayName, bio, birthday, selectedImageUri) },
enabled = !isSaving
) {
Text(
if (isSaving) stringResource(R.string.saving) else stringResource(R.string.save),
fontWeight = FontWeight.Bold,
color = if (isSaving) Color.Gray else MaterialTheme.colorScheme.primary
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}
) { paddingValues ->
@@ -132,13 +197,14 @@ fun EditProfileScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = stringResource(R.string.change_photo),
tint = Color.White
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(28.dp)
)
}
}
@@ -194,14 +260,65 @@ fun EditProfileScreen(
maxLines = 5
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = formattedBirthday,
onValueChange = { },
label = { Text("Дата рождения") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = SoftSquareShape,
placeholder = { Text("ДД.ММ.ГГГГ") },
trailingIcon = {
Row {
if (birthday.isNotEmpty()) {
IconButton(onClick = { birthday = "" }) {
Icon(
Icons.Default.Clear,
contentDescription = "Очистить",
tint = Color.Gray
)
}
}
IconButton(onClick = { showDatePicker = true }) {
Icon(
Icons.Default.CalendarToday,
contentDescription = "Выбрать дату",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { onSave(displayName, bio, selectedImageUri) },
onClick = { onSave(displayName, bio, birthday, selectedImageUri) },
modifier = Modifier.fillMaxWidth(),
shape = SoftSquareShape
shape = SoftSquareShape,
enabled = !isSaving
) {
Text(stringResource(R.string.save))
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.saving))
} else {
Text(stringResource(R.string.save))
}
}
}
}

View File

@@ -41,13 +41,17 @@ fun ProfileScreen(
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(profileId) {
// Загружаем профиль при первом запуске
LaunchedEffect(Unit) {
android.util.Log.d("ProfileScreen", "Loading profile with profileId=$profileId")
viewModel.loadProfile(profileId)
}
val profile = state.profile
val isOwnProfile = profileId == null
android.util.Log.d("ProfileScreen", "Profile loaded: displayName=${profile?.displayName}, bio=${profile?.bio}, birthday=${profile?.birthday}")
val mediaTabs = listOf(
stringResource(R.string.media),
"GIF",
@@ -68,7 +72,7 @@ fun ProfileScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile)) },
title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -80,7 +84,13 @@ fun ProfileScreen(
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit_profile))
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White,
actionIconContentColor = Color.White,
navigationIconContentColor = Color.White
)
)
}
) { paddingValues ->
@@ -95,6 +105,7 @@ fun ProfileScreen(
displayName = profile?.displayName,
avatarUrl = profile?.avatarUrl,
bio = profile?.bio,
birthday = profile?.birthday,
isOwnProfile = isOwnProfile,
isCallsEnabled = true,
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
@@ -143,6 +154,7 @@ fun ProfileHeader(
displayName: String?,
avatarUrl: String?,
bio: String?,
birthday: String?,
isOwnProfile: Boolean,
isCallsEnabled: Boolean,
onSendMessage: () -> Unit,
@@ -190,11 +202,27 @@ fun ProfileHeader(
}
}
if (bio != null) {
if (bio != null || birthday != 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)
if (birthday != null) {
Text(text = "Дата рождения", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
// Форматируем дату из ISO8601 в dd.MM.yyyy
val formattedBirthday = try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
val date = format.parse(birthday)
val outputFormat = java.text.SimpleDateFormat("dd.MM.yyyy", java.util.Locale.getDefault())
date?.let { outputFormat.format(it) } ?: birthday
} catch (e: Exception) {
birthday
}
Text(text = formattedBirthday, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
}
if (bio != null) {
Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(text = bio, style = MaterialTheme.typography.bodyMedium)
}
}
}
}

View File

@@ -44,23 +44,38 @@ class ProfileViewModel @Inject constructor(
}
}
fun updateProfile(displayName: String?, bio: String?, avatarUri: Uri? = null) {
fun updateProfile(displayName: String?, bio: String?, birthday: String? = null, avatarUri: Uri? = null) {
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
_state.value = _state.value.copy(isSaving = true, error = null)
// 1. Обновляем аватар, если он выбран
avatarUri?.let {
repository.uploadAvatar(it)
}
try {
// 1. Обновляем аватар, если он выбран
avatarUri?.let { uri ->
repository.uploadAvatar(uri).onFailure { error ->
throw Exception(error.message ?: "Failed to upload avatar")
}
}
// 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)
}
// 2. Обновляем текстовые данные
// Пустую строку преобразуем в null для даты рождения
val birthdayToSend = if (birthday.isNullOrBlank()) null else birthday
// Логирование для отладки
android.util.Log.d("ProfileViewModel", "updateProfile: displayName=$displayName, bio=$bio, birthday=$birthday, birthdayToSend=$birthdayToSend")
repository.updateProfile(displayName, bio, birthdayToSend)
.onSuccess { updatedProfile ->
android.util.Log.d("ProfileViewModel", "updateProfile success: birthday=${updatedProfile.birthday}")
_state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
}
.onFailure { error ->
android.util.Log.e("ProfileViewModel", "updateProfile error: ${error.message}")
_state.value = _state.value.copy(error = error.message, isSaving = false)
}
} catch (e: Exception) {
android.util.Log.e("ProfileViewModel", "updateProfile exception: ${e.message}")
_state.value = _state.value.copy(error = e.message, isSaving = false)
}
}
}