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) {
@@ -5,6 +5,8 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import com.galo.android.domain.model.Album
import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
import com.galo.android.ui.theme.GaloTheme
import org.junit.Rule
import org.junit.Test
@@ -89,4 +91,39 @@ class AlbumsContentTest {
}
compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed()
}
@Test
fun `header shows the silhouette placeholder without an avatar`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
AlbumsContent(
state = AlbumsViewModel.UiState(),
onOpenAlbum = {},
onOpenUploads = {},
onOpenProfile = {},
onRefresh = {},
onLogout = {},
)
}
}
compose.onNodeWithTag(TAG_ALBUMS_AVATAR).assertExists()
compose.onNodeWithTag(TAG_USER_AVATAR_PLACEHOLDER, useUnmergedTree = true).assertIsDisplayed()
}
@Test
fun `header shows the avatar image when set`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
AlbumsContent(
state = AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1"),
onOpenAlbum = {},
onOpenUploads = {},
onOpenProfile = {},
onRefresh = {},
onLogout = {},
)
}
}
compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed()
}
}
@@ -0,0 +1,57 @@
package com.galo.android.ui.profile
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import com.galo.android.ui.theme.GaloTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], qualifiers = "w411dp-h2400dp")
class AvatarCropDialogTest {
@get:Rule
val compose = createComposeRule()
private fun image() = Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).asImageBitmap()
@Test
fun `confirm renders a square jpeg`() {
var result: ByteArray? = null
compose.setContent {
GaloTheme(dynamicColor = false) {
AvatarCropDialog(image = image(), onCancel = {}, onConfirm = { result = it })
}
}
compose.onNodeWithTag(TAG_CROP_CONFIRM).performClick()
compose.waitForIdle()
assertNotNull(result)
val decoded = BitmapFactory.decodeByteArray(result, 0, result!!.size)
assertNotNull(decoded)
assertEquals(512, decoded.width)
assertEquals(512, decoded.height)
}
@Test
fun `cancel invokes the callback`() {
var cancelled = false
compose.setContent {
GaloTheme(dynamicColor = false) {
AvatarCropDialog(image = image(), onCancel = { cancelled = true }, onConfirm = {})
}
}
compose.onNodeWithTag(TAG_CROP_CANCEL).performClick()
assertTrue(cancelled)
}
}
@@ -91,4 +91,24 @@ class ProfileContentTest {
compose.onNodeWithTag(TAG_PROFILE_DELETE).performClick()
assertTrue(deleted)
}
@Test
fun `avatar placeholder and pick button are shown without a photo`() {
render(ProfileViewModel.UiState(loading = false, name = "Tester"))
compose.onNodeWithTag(TAG_PROFILE_AVATAR).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_AVATAR_PICK).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_AVATAR_REMOVE).assertDoesNotExist()
}
@Test
fun `remove button appears once an avatar is set`() {
render(
ProfileViewModel.UiState(
loading = false,
name = "Tester",
avatarUrl = "http://host/api/avatars/u1/v1",
),
)
compose.onNodeWithTag(TAG_PROFILE_AVATAR_REMOVE).assertIsDisplayed()
}
}
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.RequestBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -37,6 +38,8 @@ class ProfileViewModelTest {
private class FakeApi : ProfileApi {
var changed: Pair<String, String>? = null
var deletedPassword: String? = null
var uploadedAvatar = false
var deletedAvatar = false
override suspend fun me(): UserDto =
UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = "Tester")
@@ -59,6 +62,14 @@ class ProfileViewModelTest {
override suspend fun deleteAccount(password: String) {
deletedPassword = password
}
override suspend fun uploadAvatar(body: RequestBody): UserDto =
UserDto(id = "u1", email = "a@test.local", name = "Tester", avatarUrl = "/api/avatars/u1/v1")
.also { uploadedAvatar = true }
override suspend fun deleteAvatar(): UserDto =
UserDto(id = "u1", email = "a@test.local", name = "Tester", avatarUrl = null)
.also { deletedAvatar = true }
}
@Test
@@ -112,4 +123,26 @@ class ProfileViewModelTest {
assertEquals("New Name", vm.state.value.name)
assertTrue(vm.state.value.notice != null)
}
@Test
fun `avatar upload stores the resolved url`() {
val api = FakeApi()
val vm = ProfileViewModel(api, InMemoryTokenStore()) { it?.let { raw -> "http://host$raw" } }
vm.uploadAvatar(byteArrayOf(1, 2, 3))
assertTrue(api.uploadedAvatar)
assertEquals("http://host/api/avatars/u1/v1", vm.state.value.avatarUrl)
}
@Test
fun `avatar removal clears the url`() {
val api = FakeApi()
val vm = ProfileViewModel(api, InMemoryTokenStore()) { it?.let { raw -> "http://host$raw" } }
vm.removeAvatar()
assertTrue(api.deletedAvatar)
assertNull(vm.state.value.avatarUrl)
}
}