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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user