Профиль, редактирование без аватара
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user