From 05a456b8724696e23c849bd5b39bf942c91ee81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurent=20Tr=C3=A9guier?= Date: Sun, 6 Oct 2024 22:10:15 +0200 Subject: [PATCH] Allow removing avatar --- .../fyreplace/fyreplace/fakes/Responses.kt | 2 + .../fakes/api/FakeUsersEndpointApi.kt | 4 +- .../fyreplace/ui/screens/LoginScreen.kt | 6 +- .../fyreplace/ui/screens/RegisterScreen.kt | 6 +- .../fyreplace/ui/screens/SettingsScreen.kt | 120 +++--------------- .../fyreplace/fyreplace/ui/views/Avatar.kt | 43 ++++--- .../fyreplace/ui/views/account/Logo.kt | 2 +- .../fyreplace/ui/views/bars/TopBar.kt | 42 +++--- .../ui/views/navigation/Destination.kt | 2 + .../ui/views/settings/AvatarPreference.kt | 108 ++++++++++++++++ .../fyreplace/ui/views/settings/Preference.kt | 77 +++++++++++ .../fyreplace/ui/views/settings/Section.kt | 23 ++++ .../viewmodels/screens/SettingsViewModel.kt | 37 +++--- app/src/main/res/values/sizes.xml | 3 +- app/src/main/res/values/strings.xml | 4 + .../test/screens/SettingsViewModelTests.kt | 25 +++- 16 files changed, 333 insertions(+), 171 deletions(-) create mode 100644 app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/AvatarPreference.kt create mode 100644 app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Preference.kt create mode 100644 app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Section.kt diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/Responses.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/Responses.kt index 49fe9ca..0de886b 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/Responses.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/Responses.kt @@ -8,6 +8,8 @@ fun ok(body: T): Response = Response.success(body) fun created(body: T): Response = Response.success(body) +fun noContent(): Response = Response.success(null) + fun badRequest(): Response = error(400, "Bad Request".toResponseBody()) fun forbidden(): Response = error(403, "Forbidden".toResponseBody()) diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/api/FakeUsersEndpointApi.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/api/FakeUsersEndpointApi.kt index dfed676..4882a38 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/api/FakeUsersEndpointApi.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/fakes/api/FakeUsersEndpointApi.kt @@ -11,6 +11,7 @@ import app.fyreplace.fyreplace.fakes.conflict import app.fyreplace.fyreplace.fakes.created import app.fyreplace.fyreplace.fakes.forbidden import app.fyreplace.fyreplace.fakes.make +import app.fyreplace.fyreplace.fakes.noContent import app.fyreplace.fyreplace.fakes.ok import app.fyreplace.fyreplace.fakes.payloadTooLarge import app.fyreplace.fyreplace.fakes.placeholder @@ -39,8 +40,7 @@ class FakeUsersEndpointApi : UsersEndpointApi { override suspend fun deleteCurrentUser(): Response = throw NotImplementedError() - override suspend fun deleteCurrentUserAvatar(): Response = - throw NotImplementedError() + override suspend fun deleteCurrentUserAvatar() = noContent() override suspend fun getCurrentUser() = ok(User.placeholder) diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/LoginScreen.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/LoginScreen.kt index 3de0fbc..9dd1d56 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/LoginScreen.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/LoginScreen.kt @@ -76,7 +76,7 @@ fun SharedTransitionScope.LoginScreen( modifier = Modifier .verticalScroll(rememberScrollState()) .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.spacing_large)) + .padding(horizontal = dimensionResource(R.dimen.spacing_huge)) .imePadding() ) { Logo( @@ -107,7 +107,7 @@ fun SharedTransitionScope.LoginScreen( dimensionResource(R.dimen.form_max_width) ) .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.spacing_large)) + .padding(bottom = dimensionResource(R.dimen.spacing_huge)) OutlinedTextField( value = identifier, @@ -153,7 +153,7 @@ fun SharedTransitionScope.LoginScreen( onCancel = viewModel::cancel, modifier = Modifier .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.spacing_large)) + .padding(bottom = dimensionResource(R.dimen.spacing_huge)) .sharedElement( rememberSharedContentState(key = "submit"), visibilityScope diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/RegisterScreen.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/RegisterScreen.kt index a7d48e1..24548a2 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/RegisterScreen.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/RegisterScreen.kt @@ -88,7 +88,7 @@ fun SharedTransitionScope.RegisterScreen( modifier = Modifier .verticalScroll(rememberScrollState()) .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.spacing_large)) + .padding(horizontal = dimensionResource(R.dimen.spacing_huge)) .imePadding() ) { Logo( @@ -119,7 +119,7 @@ fun SharedTransitionScope.RegisterScreen( dimensionResource(R.dimen.form_max_width) ) .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.spacing_large)) + .padding(bottom = dimensionResource(R.dimen.spacing_huge)) OutlinedTextField( value = username, @@ -224,7 +224,7 @@ fun SharedTransitionScope.RegisterScreen( onCancel = viewModel::cancel, modifier = Modifier .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.spacing_large)) + .padding(bottom = dimensionResource(R.dimen.spacing_huge)) .sharedElement( rememberSharedContentState(key = "submit"), visibilityScope diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt index 0b4757c..6e745d0 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt @@ -1,149 +1,59 @@ package app.fyreplace.fyreplace.ui.screens -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropTarget -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.Button +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.mimeTypes -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.fyreplace.api.data.User import app.fyreplace.fyreplace.R -import app.fyreplace.fyreplace.extensions.activity import app.fyreplace.fyreplace.fakes.FakeApiResolver import app.fyreplace.fyreplace.fakes.FakeEventBus import app.fyreplace.fyreplace.fakes.FakeStoreResolver import app.fyreplace.fyreplace.fakes.placeholder import app.fyreplace.fyreplace.ui.theme.AppTheme -import app.fyreplace.fyreplace.ui.views.Avatar +import app.fyreplace.fyreplace.ui.views.settings.AvatarPreference +import app.fyreplace.fyreplace.ui.views.settings.Preference +import app.fyreplace.fyreplace.ui.views.settings.Section import app.fyreplace.fyreplace.viewmodels.screens.SettingsViewModel -import java.io.File -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) { Column( verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)), - horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() - UserInfo(user = currentUser, onAvatarFile = viewModel::updateAvatar) - Button(onClick = viewModel::logout) { - Text(stringResource(R.string.settings_logout)) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun UserInfo(user: User?, onAvatarFile: (File) -> Unit) { - val activity = activity - val dateFormatter = remember { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) } - val loading = stringResource(R.string.loading) - val avatarSize = 128.dp - val avatarInteraction = remember { MutableInteractionSource() } - val isAvatarHovered by avatarInteraction.collectIsHoveredAsState() - val avatarDropTarget = remember { requireNotNull(activity).makeFileDropTarget(onAvatarFile) } - val isAvatarUpdatable = isAvatarHovered || avatarDropTarget.isReady - val avatarBlur by animateDpAsState( - targetValue = if (isAvatarUpdatable) 1.dp else 0.dp, - label = "Avatar blur" - ) - - Box(contentAlignment = Alignment.Center) { - Avatar( - user = user, - tinted = true, - size = avatarSize, - modifier = Modifier - .blur(avatarBlur) - .hoverable(avatarInteraction) - .clickable { activity?.selectImage(onAvatarFile) } - .dragAndDropTarget( - shouldStartDragAndDrop = { dropEvent -> - dropEvent - .mimeTypes() - .any { it.startsWith("image/") } - }, - target = avatarDropTarget - ) - ) + Section(stringResource(R.string.settings_header_profile)) { + AvatarPreference( + user = currentUser, + onUpdateAvatar = viewModel::updateAvatar, + onRemoveAvatar = viewModel::removeAvatar + ) - AnimatedVisibility( - visible = isAvatarUpdatable, - enter = fadeIn(), - exit = fadeOut() - ) { - Icon( - imageVector = Icons.Default.Upload, - contentDescription = null, - tint = Color.White, - modifier = Modifier - .size(avatarSize) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(avatarSize / 4) + Preference( + title = stringResource(R.string.settings_logout), + summary = stringResource(R.string.settings_logout_summary), + icon = { Icon(Icons.AutoMirrored.Filled.Logout, null) }, + onClick = viewModel::logout ) } } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = user?.username ?: loading, - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = when (user) { - null -> loading - else -> stringResource( - R.string.settings_date_joined, - dateFormatter.format(user.dateCreated) - ) - }, - style = MaterialTheme.typography.titleMedium - ) - } } @Preview(showSystemUi = true, showBackground = true) diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/Avatar.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/Avatar.kt index b612700..7aa1938 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/Avatar.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/Avatar.kt @@ -1,10 +1,12 @@ package app.fyreplace.fyreplace.ui.views +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.AccountCircle +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.twotone.Error +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -12,36 +14,45 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import app.fyreplace.api.data.User +import app.fyreplace.fyreplace.R import app.fyreplace.fyreplace.extensions.composeColor +import app.fyreplace.fyreplace.fakes.placeholder import coil.compose.AsyncImage @Composable -fun Avatar( - user: User?, - tinted: Boolean, - modifier: Modifier = Modifier, - size: Dp -) { - val fallback = rememberVectorPainter(Icons.TwoTone.AccountCircle) +fun Avatar(user: User?, modifier: Modifier = Modifier) { + val fallback = rememberVectorPainter(Icons.Filled.AccountCircle) val error = rememberVectorPainter(Icons.TwoTone.Error) val hasAvatar = !user?.avatar.isNullOrEmpty() AsyncImage( - model = if (hasAvatar) user?.avatar else null, + model = if (hasAvatar) user.avatar else null, placeholder = fallback, error = error, fallback = fallback, contentDescription = user?.username, contentScale = ContentScale.Crop, colorFilter = when { - !tinted || hasAvatar || user == null -> null - else -> ColorFilter.tint(user.tint.composeColor) + hasAvatar -> null + user != null -> ColorFilter.tint(user.tint.composeColor) + else -> ColorFilter.tint(MaterialTheme.colorScheme.onBackground) }, - modifier = Modifier - .size(size) - .scale(if (hasAvatar) 1f else 1.2f) + modifier = modifier .clip(CircleShape) - .then(modifier) + .scale(if (hasAvatar) 1f else 1.2f) + ) +} + +@Preview(showBackground = true) +@Composable +fun AvatarPreview() { + Avatar( + user = User.placeholder, + modifier = Modifier + .padding(dimensionResource(R.dimen.spacing_medium)) + .size(128.dp) ) } diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/account/Logo.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/account/Logo.kt index 5dfdf74..3b1650f 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/account/Logo.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/account/Logo.kt @@ -21,7 +21,7 @@ fun Logo(modifier: Modifier = Modifier) { colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), contentDescription = null, modifier = Modifier - .padding(vertical = dimensionResource(R.dimen.spacing_large)) + .padding(vertical = dimensionResource(R.dimen.spacing_huge)) .size(96.dp) .then(modifier) ) diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt index 25a9ce6..a7458a3 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -28,26 +29,33 @@ fun TopBar( selectedDestination: Destination.Singleton?, enabled: Boolean, onClickDestination: (Destination.Singleton) -> Unit -) = if (destinations.isNotEmpty()) { - CenterAlignedTopAppBar(title = { - SharedTransitionLayout { - AnimatedContent(destinations, label = "Top bar segments") { - SegmentedChoice( - destinations = it, - selectedDestination = selectedDestination, - enabled = enabled, - visibilityScope = this, - onClick = onClickDestination, - ) - } - } - }) -} else { - TopAppBar(title = { +) { + @Composable + fun MaybeTitle() { if (selectedDestination != null) { Text(stringResource(selectedDestination.labelRes)) } - }) + } + + if (destinations.isNotEmpty()) { + CenterAlignedTopAppBar(title = { + SharedTransitionLayout { + AnimatedContent(destinations, label = "Top bar segments") { + SegmentedChoice( + destinations = it, + selectedDestination = selectedDestination, + enabled = enabled, + visibilityScope = this, + onClick = onClickDestination, + ) + } + } + }) + } else if (selectedDestination?.hasLargeTitle == true) { + LargeTopAppBar(title = { MaybeTitle() }) + } else { + TopAppBar(title = { MaybeTitle() }) + } } @Preview diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/navigation/Destination.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/navigation/Destination.kt index 5842edb..ee7ea30 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/navigation/Destination.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/navigation/Destination.kt @@ -40,6 +40,7 @@ sealed interface Destination { val activeIcon: ImageVector val inactiveIcon: ImageVector val requiresAuthentication: Boolean + val hasLargeTitle: Boolean get() = false @get:StringRes val labelRes: Int @@ -123,6 +124,7 @@ sealed interface Destination { override val inactiveIcon = Icons.Outlined.Settings override val labelRes = R.string.main_destination_settings override val requiresAuthentication = false + override val hasLargeTitle = true } @Serializable diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/AvatarPreference.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/AvatarPreference.kt new file mode 100644 index 0000000..78ce072 --- /dev/null +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/AvatarPreference.kt @@ -0,0 +1,108 @@ +package app.fyreplace.fyreplace.ui.views.settings + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Upload +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.fyreplace.api.data.User +import app.fyreplace.fyreplace.R +import app.fyreplace.fyreplace.extensions.activity +import app.fyreplace.fyreplace.ui.views.Avatar +import java.io.File +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AvatarPreference( + user: User?, + onUpdateAvatar: (File) -> Unit, + onRemoveAvatar: () -> Unit +) { + val activity = activity + val haptics = LocalHapticFeedback.current + val dateJoined = user?.dateCreated + val dateFormatter = remember { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) } + var isAvatarMenuExpanded by remember { mutableStateOf(false) } + val avatarDropTarget = remember { activity?.makeFileDropTarget(onUpdateAvatar) } + val dropModifier = when (avatarDropTarget) { + null -> Modifier + else -> Modifier + .dragAndDropTarget( + shouldStartDragAndDrop = { dropEvent -> + dropEvent + .mimeTypes() + .any { it.startsWith("image/") } + }, + target = avatarDropTarget + ) + .background(if (avatarDropTarget.isReady) Color.Gray.copy(alpha = 0.25f) else Color.Transparent) + } + + fun selectImage() { + activity?.selectImage(onUpdateAvatar) + } + + Box(modifier = dropModifier) { + Preference( + title = user?.username ?: stringResource(R.string.loading), + summary = when (dateJoined) { + null -> stringResource(R.string.loading) + else -> stringResource( + R.string.settings_date_joined, + dateFormatter.format(dateJoined) + ) + }, + icon = { + Avatar(user = user, modifier = Modifier.size(64.dp)) + }, + onClick = ::selectImage, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + isAvatarMenuExpanded = true + } + ) + + DropdownMenu( + isAvatarMenuExpanded, + onDismissRequest = { isAvatarMenuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_avatar_change)) }, + leadingIcon = { Icon(Icons.Outlined.Upload, null) }, + onClick = { + selectImage() + isAvatarMenuExpanded = false + } + ) + DropdownMenuItem( + enabled = !user?.avatar.isNullOrEmpty(), + text = { Text(stringResource(R.string.settings_avatar_remove)) }, + leadingIcon = { Icon(Icons.Outlined.Delete, null) }, + onClick = { + onRemoveAvatar() + isAvatarMenuExpanded = false + } + ) + } + } +} diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Preference.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Preference.kt new file mode 100644 index 0000000..b40fa5f --- /dev/null +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Preference.kt @@ -0,0 +1,77 @@ +package app.fyreplace.fyreplace.ui.views.settings + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.fyreplace.fyreplace.R + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Preference( + title: String, + summary: String? = null, + icon: @Composable () -> Unit = {}, + onClick: () -> Unit = {}, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_large)), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onLongClick) + .fillMaxWidth() + .heightIn(min = 80.dp) + .padding( + horizontal = dimensionResource(R.dimen.spacing_large), + vertical = dimensionResource(R.dimen.spacing_medium) + ) + .then(modifier) + ) { + icon() + + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreferencePreview() { + Preference( + title = "Title", + summary = "Summary", + icon = { + Icon(Icons.TwoTone.Image, null) + } + ) +} diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Section.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Section.kt new file mode 100644 index 0000000..ec3e747 --- /dev/null +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/settings/Section.kt @@ -0,0 +1,23 @@ +package app.fyreplace.fyreplace.ui.views.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import app.fyreplace.fyreplace.R + +@Composable +fun Section(header: String, content: @Composable () -> Unit) { + Column { + Text( + text = header, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = dimensionResource(R.dimen.spacing_medium)) + ) + + content() + } +} diff --git a/app/src/main/kotlin/app/fyreplace/fyreplace/viewmodels/screens/SettingsViewModel.kt b/app/src/main/kotlin/app/fyreplace/fyreplace/viewmodels/screens/SettingsViewModel.kt index 431c6d1..20ac8ba 100644 --- a/app/src/main/kotlin/app/fyreplace/fyreplace/viewmodels/screens/SettingsViewModel.kt +++ b/app/src/main/kotlin/app/fyreplace/fyreplace/viewmodels/screens/SettingsViewModel.kt @@ -45,26 +45,29 @@ class SettingsViewModel @Inject constructor( } } - fun updateAvatar(file: File) { - call(apiResolver::users) { - val avatar = setCurrentUserAvatar(file).failWith { - when (it.code) { - 413 -> Event.Failure( - R.string.settings_error_413_title, - R.string.settings_error_413_message - ) + fun updateAvatar(file: File) = call(apiResolver::users) { + val avatar = setCurrentUserAvatar(file).failWith { + when (it.code) { + 413 -> Event.Failure( + R.string.settings_error_413_title, + R.string.settings_error_413_message + ) - 415 -> Event.Failure( - R.string.settings_error_415_title, - R.string.settings_error_415_message - ) + 415 -> Event.Failure( + R.string.settings_error_415_title, + R.string.settings_error_415_message + ) - else -> Event.Failure() - } - } ?: return@call + else -> Event.Failure() + } + } ?: return@call - state[::currentUser.name] = currentUser.value?.copy(avatar = avatar) - } + state[::currentUser.name] = currentUser.value?.copy(avatar = avatar) + } + + fun removeAvatar() = call(apiResolver::users) { + deleteCurrentUserAvatar().require() + state[::currentUser.name] = currentUser.value?.copy(avatar = "") } fun logout() { diff --git a/app/src/main/res/values/sizes.xml b/app/src/main/res/values/sizes.xml index 1461108..a3d44f7 100644 --- a/app/src/main/res/values/sizes.xml +++ b/app/src/main/res/values/sizes.xml @@ -2,7 +2,8 @@ 8dp 16dp - 32dp + 24dp + 32dp 280dp 600dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 728f90d..f533853 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,8 +16,12 @@ Login Sign up + Profile Joined: %1$s + Change avatar + Remove avatar Logout + Disconnect from this account File too large This file is too heavy; please select a file under 1M. Unsupported file format diff --git a/app/src/test/kotlin/app/fyreplace/fyreplace/test/screens/SettingsViewModelTests.kt b/app/src/test/kotlin/app/fyreplace/fyreplace/test/screens/SettingsViewModelTests.kt index aa6f0fd..6885ed8 100644 --- a/app/src/test/kotlin/app/fyreplace/fyreplace/test/screens/SettingsViewModelTests.kt +++ b/app/src/test/kotlin/app/fyreplace/fyreplace/test/screens/SettingsViewModelTests.kt @@ -34,12 +34,12 @@ class SettingsViewModelTests : TestsBase() { } @Test - fun `Too large avatar produces a failure`() = runTest { + fun `Updating avatar with a too large image produces a failure`() = runTest { val eventBus = FakeEventBus() val viewModel = makeViewModel(eventBus) + runCurrent() backgroundScope.launch { viewModel.currentUser.collect() } - runCurrent() viewModel.updateAvatar(FakeUsersEndpointApi.LARGE_IMAGE_FILE) runCurrent() assertEquals(1, eventBus.storedEvents.filterIsInstance().count()) @@ -47,12 +47,12 @@ class SettingsViewModelTests : TestsBase() { } @Test - fun `Not image avatar produces a failure`() = runTest { + fun `Updating avatar with an invalid produces a failure`() = runTest { val eventBus = FakeEventBus() val viewModel = makeViewModel(eventBus) + runCurrent() backgroundScope.launch { viewModel.currentUser.collect() } - runCurrent() viewModel.updateAvatar(FakeUsersEndpointApi.NOT_IMAGE_FILE) runCurrent() assertEquals(1, eventBus.storedEvents.filterIsInstance().count()) @@ -60,12 +60,12 @@ class SettingsViewModelTests : TestsBase() { } @Test - fun `Valid avatar produces no failures`() = runTest { + fun `Updating avatar with a valid image produces no failures`() = runTest { val eventBus = FakeEventBus() val viewModel = makeViewModel(eventBus) + runCurrent() backgroundScope.launch { viewModel.currentUser.collect() } - runCurrent() viewModel.updateAvatar(FakeUsersEndpointApi.NORMAL_IMAGE_FILE) runCurrent() assertEquals(0, eventBus.storedEvents.filterIsInstance().count()) @@ -75,6 +75,19 @@ class SettingsViewModelTests : TestsBase() { ) } + @Test + fun `Removing avatar produces no failures`() = runTest { + val eventBus = FakeEventBus() + val viewModel = makeViewModel(eventBus) + viewModel.updateAvatar(FakeUsersEndpointApi.NORMAL_IMAGE_FILE) + backgroundScope.launch { viewModel.currentUser.collect() } + + viewModel.removeAvatar() + runCurrent() + assertEquals(0, eventBus.storedEvents.filterIsInstance().count()) + assertEquals("", viewModel.currentUser.value?.avatar) + } + private suspend fun makeViewModel(eventBus: EventBus) = SettingsViewModel( SavedStateHandle(), eventBus,