Skip to content

Commit 05a456b

Browse files
Allow removing avatar
1 parent a3fa4c1 commit 05a456b

File tree

16 files changed

+333
-171
lines changed

16 files changed

+333
-171
lines changed

app/src/main/kotlin/app/fyreplace/fyreplace/fakes/Responses.kt

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ fun <T> ok(body: T): Response<T> = Response.success(body)
88

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

11+
fun <T> noContent(): Response<T> = Response.success(null)
12+
1113
fun <T> badRequest(): Response<T> = error(400, "Bad Request".toResponseBody())
1214

1315
fun <T> forbidden(): Response<T> = error(403, "Forbidden".toResponseBody())

app/src/main/kotlin/app/fyreplace/fyreplace/fakes/api/FakeUsersEndpointApi.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import app.fyreplace.fyreplace.fakes.conflict
1111
import app.fyreplace.fyreplace.fakes.created
1212
import app.fyreplace.fyreplace.fakes.forbidden
1313
import app.fyreplace.fyreplace.fakes.make
14+
import app.fyreplace.fyreplace.fakes.noContent
1415
import app.fyreplace.fyreplace.fakes.ok
1516
import app.fyreplace.fyreplace.fakes.payloadTooLarge
1617
import app.fyreplace.fyreplace.fakes.placeholder
@@ -39,8 +40,7 @@ class FakeUsersEndpointApi : UsersEndpointApi {
3940
override suspend fun deleteCurrentUser(): Response<Unit> =
4041
throw NotImplementedError()
4142

42-
override suspend fun deleteCurrentUserAvatar(): Response<Unit> =
43-
throw NotImplementedError()
43+
override suspend fun deleteCurrentUserAvatar() = noContent<Unit>()
4444

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

app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/LoginScreen.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ fun SharedTransitionScope.LoginScreen(
7676
modifier = Modifier
7777
.verticalScroll(rememberScrollState())
7878
.fillMaxWidth()
79-
.padding(horizontal = dimensionResource(R.dimen.spacing_large))
79+
.padding(horizontal = dimensionResource(R.dimen.spacing_huge))
8080
.imePadding()
8181
) {
8282
Logo(
@@ -107,7 +107,7 @@ fun SharedTransitionScope.LoginScreen(
107107
dimensionResource(R.dimen.form_max_width)
108108
)
109109
.fillMaxWidth()
110-
.padding(bottom = dimensionResource(R.dimen.spacing_large))
110+
.padding(bottom = dimensionResource(R.dimen.spacing_huge))
111111

112112
OutlinedTextField(
113113
value = identifier,
@@ -153,7 +153,7 @@ fun SharedTransitionScope.LoginScreen(
153153
onCancel = viewModel::cancel,
154154
modifier = Modifier
155155
.fillMaxWidth()
156-
.padding(bottom = dimensionResource(R.dimen.spacing_large))
156+
.padding(bottom = dimensionResource(R.dimen.spacing_huge))
157157
.sharedElement(
158158
rememberSharedContentState(key = "submit"),
159159
visibilityScope

app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/RegisterScreen.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ fun SharedTransitionScope.RegisterScreen(
8888
modifier = Modifier
8989
.verticalScroll(rememberScrollState())
9090
.fillMaxWidth()
91-
.padding(horizontal = dimensionResource(R.dimen.spacing_large))
91+
.padding(horizontal = dimensionResource(R.dimen.spacing_huge))
9292
.imePadding()
9393
) {
9494
Logo(
@@ -119,7 +119,7 @@ fun SharedTransitionScope.RegisterScreen(
119119
dimensionResource(R.dimen.form_max_width)
120120
)
121121
.fillMaxWidth()
122-
.padding(bottom = dimensionResource(R.dimen.spacing_large))
122+
.padding(bottom = dimensionResource(R.dimen.spacing_huge))
123123

124124
OutlinedTextField(
125125
value = username,
@@ -224,7 +224,7 @@ fun SharedTransitionScope.RegisterScreen(
224224
onCancel = viewModel::cancel,
225225
modifier = Modifier
226226
.fillMaxWidth()
227-
.padding(bottom = dimensionResource(R.dimen.spacing_large))
227+
.padding(bottom = dimensionResource(R.dimen.spacing_huge))
228228
.sharedElement(
229229
rememberSharedContentState(key = "submit"),
230230
visibilityScope

app/src/main/kotlin/app/fyreplace/fyreplace/ui/screens/SettingsScreen.kt

+15-105
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,59 @@
11
package app.fyreplace.fyreplace.ui.screens
22

3-
import androidx.compose.animation.AnimatedVisibility
4-
import androidx.compose.animation.core.animateDpAsState
5-
import androidx.compose.animation.fadeIn
6-
import androidx.compose.animation.fadeOut
7-
import androidx.compose.foundation.ExperimentalFoundationApi
8-
import androidx.compose.foundation.background
9-
import androidx.compose.foundation.clickable
10-
import androidx.compose.foundation.draganddrop.dragAndDropTarget
11-
import androidx.compose.foundation.hoverable
12-
import androidx.compose.foundation.interaction.MutableInteractionSource
13-
import androidx.compose.foundation.interaction.collectIsHoveredAsState
143
import androidx.compose.foundation.layout.Arrangement
15-
import androidx.compose.foundation.layout.Box
164
import androidx.compose.foundation.layout.Column
175
import androidx.compose.foundation.layout.fillMaxWidth
18-
import androidx.compose.foundation.layout.padding
19-
import androidx.compose.foundation.layout.size
206
import androidx.compose.foundation.rememberScrollState
21-
import androidx.compose.foundation.shape.CircleShape
227
import androidx.compose.foundation.verticalScroll
238
import androidx.compose.material.icons.Icons
24-
import androidx.compose.material.icons.filled.Upload
25-
import androidx.compose.material3.Button
9+
import androidx.compose.material.icons.automirrored.filled.Logout
2610
import androidx.compose.material3.Icon
27-
import androidx.compose.material3.MaterialTheme
28-
import androidx.compose.material3.Text
2911
import androidx.compose.runtime.Composable
3012
import androidx.compose.runtime.getValue
31-
import androidx.compose.runtime.remember
32-
import androidx.compose.ui.Alignment
3313
import androidx.compose.ui.Modifier
34-
import androidx.compose.ui.draganddrop.mimeTypes
35-
import androidx.compose.ui.draw.blur
36-
import androidx.compose.ui.draw.clip
37-
import androidx.compose.ui.graphics.Color
3814
import androidx.compose.ui.res.dimensionResource
3915
import androidx.compose.ui.res.stringResource
4016
import androidx.compose.ui.tooling.preview.Preview
41-
import androidx.compose.ui.unit.dp
4217
import androidx.hilt.navigation.compose.hiltViewModel
4318
import androidx.lifecycle.SavedStateHandle
4419
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4520
import app.fyreplace.api.data.User
4621
import app.fyreplace.fyreplace.R
47-
import app.fyreplace.fyreplace.extensions.activity
4822
import app.fyreplace.fyreplace.fakes.FakeApiResolver
4923
import app.fyreplace.fyreplace.fakes.FakeEventBus
5024
import app.fyreplace.fyreplace.fakes.FakeStoreResolver
5125
import app.fyreplace.fyreplace.fakes.placeholder
5226
import app.fyreplace.fyreplace.ui.theme.AppTheme
53-
import app.fyreplace.fyreplace.ui.views.Avatar
27+
import app.fyreplace.fyreplace.ui.views.settings.AvatarPreference
28+
import app.fyreplace.fyreplace.ui.views.settings.Preference
29+
import app.fyreplace.fyreplace.ui.views.settings.Section
5430
import app.fyreplace.fyreplace.viewmodels.screens.SettingsViewModel
55-
import java.io.File
56-
import java.time.format.DateTimeFormatter
57-
import java.time.format.FormatStyle
5831

5932
@Composable
6033
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
6134
Column(
6235
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)),
63-
horizontalAlignment = Alignment.CenterHorizontally,
6436
modifier = Modifier
6537
.fillMaxWidth()
6638
.verticalScroll(rememberScrollState())
67-
.padding(vertical = dimensionResource(R.dimen.spacing_medium))
6839
) {
6940
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
7041

71-
UserInfo(user = currentUser, onAvatarFile = viewModel::updateAvatar)
72-
Button(onClick = viewModel::logout) {
73-
Text(stringResource(R.string.settings_logout))
74-
}
75-
}
76-
}
77-
78-
@OptIn(ExperimentalFoundationApi::class)
79-
@Composable
80-
private fun UserInfo(user: User?, onAvatarFile: (File) -> Unit) {
81-
val activity = activity
82-
val dateFormatter = remember { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) }
83-
val loading = stringResource(R.string.loading)
84-
val avatarSize = 128.dp
85-
val avatarInteraction = remember { MutableInteractionSource() }
86-
val isAvatarHovered by avatarInteraction.collectIsHoveredAsState()
87-
val avatarDropTarget = remember { requireNotNull(activity).makeFileDropTarget(onAvatarFile) }
88-
val isAvatarUpdatable = isAvatarHovered || avatarDropTarget.isReady
89-
val avatarBlur by animateDpAsState(
90-
targetValue = if (isAvatarUpdatable) 1.dp else 0.dp,
91-
label = "Avatar blur"
92-
)
93-
94-
Box(contentAlignment = Alignment.Center) {
95-
Avatar(
96-
user = user,
97-
tinted = true,
98-
size = avatarSize,
99-
modifier = Modifier
100-
.blur(avatarBlur)
101-
.hoverable(avatarInteraction)
102-
.clickable { activity?.selectImage(onAvatarFile) }
103-
.dragAndDropTarget(
104-
shouldStartDragAndDrop = { dropEvent ->
105-
dropEvent
106-
.mimeTypes()
107-
.any { it.startsWith("image/") }
108-
},
109-
target = avatarDropTarget
110-
)
111-
)
42+
Section(stringResource(R.string.settings_header_profile)) {
43+
AvatarPreference(
44+
user = currentUser,
45+
onUpdateAvatar = viewModel::updateAvatar,
46+
onRemoveAvatar = viewModel::removeAvatar
47+
)
11248

113-
AnimatedVisibility(
114-
visible = isAvatarUpdatable,
115-
enter = fadeIn(),
116-
exit = fadeOut()
117-
) {
118-
Icon(
119-
imageVector = Icons.Default.Upload,
120-
contentDescription = null,
121-
tint = Color.White,
122-
modifier = Modifier
123-
.size(avatarSize)
124-
.clip(CircleShape)
125-
.background(Color.Black.copy(alpha = 0.5f))
126-
.padding(avatarSize / 4)
49+
Preference(
50+
title = stringResource(R.string.settings_logout),
51+
summary = stringResource(R.string.settings_logout_summary),
52+
icon = { Icon(Icons.AutoMirrored.Filled.Logout, null) },
53+
onClick = viewModel::logout
12754
)
12855
}
12956
}
130-
131-
Column(horizontalAlignment = Alignment.CenterHorizontally) {
132-
Text(
133-
text = user?.username ?: loading,
134-
style = MaterialTheme.typography.headlineMedium
135-
)
136-
Text(
137-
text = when (user) {
138-
null -> loading
139-
else -> stringResource(
140-
R.string.settings_date_joined,
141-
dateFormatter.format(user.dateCreated)
142-
)
143-
},
144-
style = MaterialTheme.typography.titleMedium
145-
)
146-
}
14757
}
14858

14959
@Preview(showSystemUi = true, showBackground = true)
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,58 @@
11
package app.fyreplace.fyreplace.ui.views
22

3+
import androidx.compose.foundation.layout.padding
34
import androidx.compose.foundation.layout.size
45
import androidx.compose.foundation.shape.CircleShape
56
import androidx.compose.material.icons.Icons
6-
import androidx.compose.material.icons.twotone.AccountCircle
7+
import androidx.compose.material.icons.filled.AccountCircle
78
import androidx.compose.material.icons.twotone.Error
9+
import androidx.compose.material3.MaterialTheme
810
import androidx.compose.runtime.Composable
911
import androidx.compose.ui.Modifier
1012
import androidx.compose.ui.draw.clip
1113
import androidx.compose.ui.draw.scale
1214
import androidx.compose.ui.graphics.ColorFilter
1315
import androidx.compose.ui.graphics.vector.rememberVectorPainter
1416
import androidx.compose.ui.layout.ContentScale
15-
import androidx.compose.ui.unit.Dp
17+
import androidx.compose.ui.res.dimensionResource
18+
import androidx.compose.ui.tooling.preview.Preview
19+
import androidx.compose.ui.unit.dp
1620
import app.fyreplace.api.data.User
21+
import app.fyreplace.fyreplace.R
1722
import app.fyreplace.fyreplace.extensions.composeColor
23+
import app.fyreplace.fyreplace.fakes.placeholder
1824
import coil.compose.AsyncImage
1925

2026
@Composable
21-
fun Avatar(
22-
user: User?,
23-
tinted: Boolean,
24-
modifier: Modifier = Modifier,
25-
size: Dp
26-
) {
27-
val fallback = rememberVectorPainter(Icons.TwoTone.AccountCircle)
27+
fun Avatar(user: User?, modifier: Modifier = Modifier) {
28+
val fallback = rememberVectorPainter(Icons.Filled.AccountCircle)
2829
val error = rememberVectorPainter(Icons.TwoTone.Error)
2930
val hasAvatar = !user?.avatar.isNullOrEmpty()
3031
AsyncImage(
31-
model = if (hasAvatar) user?.avatar else null,
32+
model = if (hasAvatar) user.avatar else null,
3233
placeholder = fallback,
3334
error = error,
3435
fallback = fallback,
3536
contentDescription = user?.username,
3637
contentScale = ContentScale.Crop,
3738
colorFilter = when {
38-
!tinted || hasAvatar || user == null -> null
39-
else -> ColorFilter.tint(user.tint.composeColor)
39+
hasAvatar -> null
40+
user != null -> ColorFilter.tint(user.tint.composeColor)
41+
else -> ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
4042
},
41-
modifier = Modifier
42-
.size(size)
43-
.scale(if (hasAvatar) 1f else 1.2f)
43+
modifier = modifier
4444
.clip(CircleShape)
45-
.then(modifier)
45+
.scale(if (hasAvatar) 1f else 1.2f)
46+
)
47+
}
48+
49+
@Preview(showBackground = true)
50+
@Composable
51+
fun AvatarPreview() {
52+
Avatar(
53+
user = User.placeholder,
54+
modifier = Modifier
55+
.padding(dimensionResource(R.dimen.spacing_medium))
56+
.size(128.dp)
4657
)
4758
}

app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/account/Logo.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ fun Logo(modifier: Modifier = Modifier) {
2121
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
2222
contentDescription = null,
2323
modifier = Modifier
24-
.padding(vertical = dimensionResource(R.dimen.spacing_large))
24+
.padding(vertical = dimensionResource(R.dimen.spacing_huge))
2525
.size(96.dp)
2626
.then(modifier)
2727
)

app/src/main/kotlin/app/fyreplace/fyreplace/ui/views/bars/TopBar.kt

+25-17
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.animation.SharedTransitionLayout
77
import androidx.compose.animation.SharedTransitionScope
88
import androidx.compose.material3.CenterAlignedTopAppBar
99
import androidx.compose.material3.ExperimentalMaterial3Api
10+
import androidx.compose.material3.LargeTopAppBar
1011
import androidx.compose.material3.SegmentedButton
1112
import androidx.compose.material3.SegmentedButtonDefaults
1213
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -28,26 +29,33 @@ fun TopBar(
2829
selectedDestination: Destination.Singleton?,
2930
enabled: Boolean,
3031
onClickDestination: (Destination.Singleton) -> Unit
31-
) = if (destinations.isNotEmpty()) {
32-
CenterAlignedTopAppBar(title = {
33-
SharedTransitionLayout {
34-
AnimatedContent(destinations, label = "Top bar segments") {
35-
SegmentedChoice(
36-
destinations = it,
37-
selectedDestination = selectedDestination,
38-
enabled = enabled,
39-
visibilityScope = this,
40-
onClick = onClickDestination,
41-
)
42-
}
43-
}
44-
})
45-
} else {
46-
TopAppBar(title = {
32+
) {
33+
@Composable
34+
fun MaybeTitle() {
4735
if (selectedDestination != null) {
4836
Text(stringResource(selectedDestination.labelRes))
4937
}
50-
})
38+
}
39+
40+
if (destinations.isNotEmpty()) {
41+
CenterAlignedTopAppBar(title = {
42+
SharedTransitionLayout {
43+
AnimatedContent(destinations, label = "Top bar segments") {
44+
SegmentedChoice(
45+
destinations = it,
46+
selectedDestination = selectedDestination,
47+
enabled = enabled,
48+
visibilityScope = this,
49+
onClick = onClickDestination,
50+
)
51+
}
52+
}
53+
})
54+
} else if (selectedDestination?.hasLargeTitle == true) {
55+
LargeTopAppBar(title = { MaybeTitle() })
56+
} else {
57+
TopAppBar(title = { MaybeTitle() })
58+
}
5159
}
5260

5361
@Preview

0 commit comments

Comments
 (0)