Skip to content

Commit

Permalink
Allow removing avatar
Browse files Browse the repository at this point in the history
  • Loading branch information
LaurentTreguier committed Oct 6, 2024
1 parent a3fa4c1 commit 05a456b
Show file tree
Hide file tree
Showing 16 changed files with 333 additions and 171 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ fun <T> ok(body: T): Response<T> = Response.success(body)

fun <T> created(body: T): Response<T> = Response.success(body)

fun <T> noContent(): Response<T> = Response.success(null)

fun <T> badRequest(): Response<T> = error(400, "Bad Request".toResponseBody())

fun <T> forbidden(): Response<T> = error(403, "Forbidden".toResponseBody())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,8 +40,7 @@ class FakeUsersEndpointApi : UsersEndpointApi {
override suspend fun deleteCurrentUser(): Response<Unit> =
throw NotImplementedError()

override suspend fun deleteCurrentUserAvatar(): Response<Unit> =
throw NotImplementedError()
override suspend fun deleteCurrentUserAvatar() = noContent<Unit>()

override suspend fun getCurrentUser() = ok(User.placeholder)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
120 changes: 15 additions & 105 deletions app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
43 changes: 27 additions & 16 deletions app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/Avatar.kt
Original file line number Diff line number Diff line change
@@ -1,47 +1,58 @@
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
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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
42 changes: 25 additions & 17 deletions app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 05a456b

Please sign in to comment.