feat(profile): add round avatar with upload and cropping

- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs,
  avatar service (EXIF-stripping WebP 128/512), public avatar read route
- web: avatar upload/crop on profile, round avatar in header/members/comments/author
- android: avatar upload/crop, round avatar on profile and albums header
- openspec: add-user-avatar proposal/design/specs/tasks
This commit is contained in:
iu
2026-10-12 16:16:47 +03:00
parent 657a04309e
commit 04617d1cdb
49 changed files with 2362 additions and 21 deletions
@@ -21,7 +21,7 @@ class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container)
modelClass.isAssignableFrom(ProfileViewModel::class.java) ->
ProfileViewModel(container.api, container.tokenStore)
ProfileViewModel(container.api, container.tokenStore, container.apiClient.objectUrls::resolve)
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
return viewModel as T
@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -40,9 +39,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.galo.android.domain.model.Album
import com.galo.android.ui.GaloViewModelFactory
import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.common.UserAvatar
const val TAG_ALBUMS_GRID = "albums_grid"
const val TAG_ALBUMS_OFFLINE = "albums_offline"
const val TAG_ALBUMS_AVATAR = "albums_avatar"
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
@OptIn(ExperimentalMaterial3Api::class)
@@ -81,8 +82,8 @@ fun AlbumsContent(
TopAppBar(
title = { Text("Альбомы") },
actions = {
IconButton(onClick = onOpenProfile) {
Icon(Icons.Filled.Person, contentDescription = "Профиль")
IconButton(onClick = onOpenProfile, modifier = Modifier.testTag(TAG_ALBUMS_AVATAR)) {
UserAvatar(url = state.avatarUrl, contentDescription = "Профиль", size = 28.dp)
}
IconButton(onClick = onOpenUploads) {
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
@@ -19,6 +19,7 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
val loading: Boolean = false,
val offline: Boolean = false,
val error: String? = null,
val avatarUrl: String? = null,
)
private data class Status(
@@ -29,11 +30,14 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
private val status = MutableStateFlow(Status())
private val avatar = MutableStateFlow<String?>(null)
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(),
status,
) { albums, s ->
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error)
avatar,
) { albums, s, a ->
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error, avatarUrl = a)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
init {
@@ -52,6 +56,11 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
)
}
}
viewModelScope.launch {
runCatching { container.api.me() }.onSuccess { user ->
avatar.value = container.apiClient.objectUrls.resolve(user.avatarUrl)
}
}
}
fun logout(onDone: () -> Unit) {
@@ -0,0 +1,55 @@
package com.galo.android.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.platform.testTag
import androidx.compose.ui.unit.Dp
const val TAG_USER_AVATAR_IMAGE = "user_avatar_image"
const val TAG_USER_AVATAR_PLACEHOLDER = "user_avatar_placeholder"
/**
* A round user avatar: the image when a URL is known, otherwise a neutral
* silhouette placeholder. The frame is always circular.
*/
@Composable
fun UserAvatar(
url: String?,
contentDescription: String?,
size: Dp,
modifier: Modifier = Modifier,
) {
if (!url.isNullOrBlank()) {
GaloAsyncImage(
url = url,
contentDescription = contentDescription,
modifier = modifier.size(size).clip(CircleShape).testTag(TAG_USER_AVATAR_IMAGE),
)
} else {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(TAG_USER_AVATAR_PLACEHOLDER),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size * 0.6f),
)
}
}
}
@@ -0,0 +1,151 @@
package com.galo.android.ui.profile
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import java.io.ByteArrayOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
const val TAG_CROP_CONFIRM = "avatar_crop_confirm"
const val TAG_CROP_CANCEL = "avatar_crop_cancel"
/**
* A square-picker cropper. The user pans and zooms the image inside a circular
* mask; confirming renders a 512x512 JPEG of the visible square. All cropping
* happens on-device, so the server only ever receives a ready square.
*/
@Composable
fun AvatarCropDialog(
image: ImageBitmap,
onCancel: () -> Unit,
onConfirm: (ByteArray) -> Unit,
) {
var zoom by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var viewPx by remember { mutableStateOf(IntSize.Zero) }
fun clamp() {
if (viewPx.width == 0) return
val s = max(viewPx.width.toFloat() / image.width, viewPx.height.toFloat() / image.height) * zoom
val dw = image.width * s
val dh = image.height * s
val mx = max(0f, (dw - viewPx.width) / 2f)
val my = max(0f, (dh - viewPx.height) / 2f)
offset = Offset(offset.x.coerceIn(-mx, mx), offset.y.coerceIn(-my, my))
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth(0.9f)
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Кадрирование", style = MaterialTheme.typography.titleMedium)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.onSizeChanged { viewPx = it }
.pointerInput(Unit) {
detectDragGestures { change, drag ->
change.consume()
offset += drag
clamp()
}
},
) {
Canvas(Modifier.fillMaxSize()) {
val s = max(size.width / image.width, size.height / image.height) * zoom
val dw = image.width * s
val dh = image.height * s
val left = (size.width - dw) / 2f + offset.x
val top = (size.height - dh) / 2f + offset.y
drawImage(
image = image,
dstOffset = IntOffset(left.roundToInt(), top.roundToInt()),
dstSize = IntSize(dw.roundToInt(), dh.roundToInt()),
)
}
}
Slider(
value = zoom,
onValueChange = {
zoom = it
clamp()
},
valueRange = 1f..3f,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onCancel, modifier = Modifier.testTag(TAG_CROP_CANCEL)) {
Text("Отмена")
}
Button(
onClick = { onConfirm(renderCropped(image, zoom, offset, viewPx)) },
modifier = Modifier.testTag(TAG_CROP_CONFIRM),
) {
Text("Сохранить")
}
}
}
}
}
private fun renderCropped(image: ImageBitmap, zoom: Float, offset: Offset, viewPx: IntSize): ByteArray {
val bitmap = image.asAndroidBitmap()
val s = max(viewPx.width.toFloat() / bitmap.width, viewPx.height.toFloat() / bitmap.height) * zoom
val side = viewPx.width / s
val cx = bitmap.width / 2f - offset.x / s
val cy = bitmap.height / 2f - offset.y / s
val x = (cx - side / 2f).roundToInt().coerceIn(0, (bitmap.width - 1).coerceAtLeast(0))
val y = (cy - side / 2f).roundToInt().coerceIn(0, (bitmap.height - 1).coerceAtLeast(0))
val size = side.roundToInt().coerceAtLeast(1).coerceAtMost(minOf(bitmap.width - x, bitmap.height - y))
val cropped = Bitmap.createBitmap(bitmap, x, y, size, size)
val scaled = Bitmap.createScaledBitmap(cropped, 512, 512, true)
return ByteArrayOutputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 90, out)
out.toByteArray()
}
}
@@ -31,6 +31,18 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
import com.galo.android.ui.common.UserAvatar
import android.graphics.ImageDecoder
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
const val TAG_PROFILE_EMAIL = "profile_email"
const val TAG_PROFILE_PHONE = "profile_phone"
@@ -44,6 +56,9 @@ const val TAG_PROFILE_NEW_PASSWORD = "profile_new_password"
const val TAG_PROFILE_CHANGE_PASSWORD = "profile_change_password"
const val TAG_PROFILE_DELETE_PASSWORD = "profile_delete_password"
const val TAG_PROFILE_DELETE = "profile_delete"
const val TAG_PROFILE_AVATAR = "profile_avatar"
const val TAG_PROFILE_AVATAR_PICK = "profile_avatar_pick"
const val TAG_PROFILE_AVATAR_REMOVE = "profile_avatar_remove"
@Composable
fun ProfileScreen(
@@ -54,6 +69,17 @@ fun ProfileScreen(
val viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var cropImage by remember { mutableStateOf<ImageBitmap?>(null) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
runCatching {
val source = ImageDecoder.createSource(context.contentResolver, uri)
ImageDecoder.decodeBitmap(source).asImageBitmap()
}.onSuccess { cropImage = it }
}
}
LaunchedEffect(Unit) {
viewModel.loggedOut.collect { onLoggedOut() }
}
@@ -70,8 +96,21 @@ fun ProfileScreen(
onChangePassword = viewModel::changePassword,
onDeletePasswordChange = viewModel::onDeletePasswordChange,
onDeleteAccount = viewModel::deleteAccount,
onPickAvatar = { picker.launch("image/*") },
onRemoveAvatar = viewModel::removeAvatar,
onBack = onBack,
)
cropImage?.let { image ->
AvatarCropDialog(
image = image,
onCancel = { cropImage = null },
onConfirm = { bytes ->
cropImage = null
viewModel.uploadAvatar(bytes)
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -88,6 +127,8 @@ fun ProfileContent(
onChangePassword: () -> Unit,
onDeletePasswordChange: (String) -> Unit,
onDeleteAccount: () -> Unit,
onPickAvatar: () -> Unit = {},
onRemoveAvatar: () -> Unit = {},
onBack: () -> Unit,
) {
Scaffold(
@@ -121,6 +162,31 @@ fun ProfileContent(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
UserAvatar(
url = state.avatarUrl,
contentDescription = state.name,
size = 80.dp,
modifier = Modifier.testTag(TAG_PROFILE_AVATAR),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onPickAvatar, modifier = Modifier.testTag(TAG_PROFILE_AVATAR_PICK)) {
Text(if (state.avatarUrl != null) "Заменить фото" else "Загрузить фото")
}
if (state.avatarUrl != null) {
OutlinedButton(
onClick = onRemoveAvatar,
modifier = Modifier.testTag(TAG_PROFILE_AVATAR_REMOVE),
) {
Text("Удалить фото")
}
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Идентификаторы", style = MaterialTheme.typography.titleMedium)
Text(
@@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Drives the profile screen. Password change and account deletion clear the
@@ -20,6 +22,7 @@ import kotlinx.coroutines.launch
class ProfileViewModel(
private val api: ProfileApi,
private val tokenStore: TokenStore,
private val resolveUrl: (String?) -> String? = { it },
) : ViewModel() {
data class UiState(
@@ -27,6 +30,7 @@ class ProfileViewModel(
val name: String = "",
val email: String? = null,
val phone: String? = null,
val avatarUrl: String? = null,
val newEmail: String = "",
val newPhone: String = "",
val currentPassword: String = "",
@@ -52,7 +56,13 @@ class ProfileViewModel(
try {
val user = api.me()
_state.update {
it.copy(loading = false, name = user.name, email = user.email, phone = user.phone)
it.copy(
loading = false,
name = user.name,
email = user.email,
phone = user.phone,
avatarUrl = resolveUrl(user.avatarUrl),
)
}
} catch (e: ApiException) {
_state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) }
@@ -154,12 +164,41 @@ class ProfileViewModel(
_loggedOut.tryEmit(Unit)
}
/** Installs a cropped square avatar; [jpeg] is the encoded image bytes. */
fun uploadAvatar(jpeg: ByteArray) {
viewModelScope.launch {
_state.update { it.copy(error = null, notice = null) }
try {
val user = api.uploadAvatar(jpeg.toRequestBody("image/jpeg".toMediaType()))
_state.update { it.copy(avatarUrl = resolveUrl(user.avatarUrl), notice = "Аватар обновлён") }
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Не удалось загрузить аватар") }
}
}
}
fun removeAvatar() {
viewModelScope.launch {
_state.update { it.copy(error = null, notice = null) }
try {
val user = api.deleteAvatar()
_state.update { it.copy(avatarUrl = resolveUrl(user.avatarUrl), notice = "Аватар удалён") }
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Не удалось удалить аватар") }
}
}
}
private inline fun mutate(notice: String, crossinline block: suspend () -> com.galo.android.data.network.dto.UserDto) {
viewModelScope.launch {
_state.update { it.copy(error = null, notice = null) }
try {
val user = block()
_state.update { it.copy(name = user.name, email = user.email, phone = user.phone, notice = notice) }
_state.update { it.copy(name = user.name, email = user.email, phone = user.phone, avatarUrl = resolveUrl(user.avatarUrl), notice = notice) }
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {