Skip to content

Commit f3251b3

Browse files
Allow updating bio
1 parent 5e8c289 commit f3251b3

File tree

7 files changed

+140
-12
lines changed

7 files changed

+140
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package app.fyreplace.fyreplace.extensions
2+
3+
val String.codePointCount get() = codePointCount(0, length)

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ class FakeUsersEndpointApi : UsersEndpointApi {
5656
else -> unsupportedMediaType()
5757
}
5858

59-
override suspend fun setCurrentUserBio(body: String): Response<String> =
60-
throw NotImplementedError()
59+
override suspend fun setCurrentUserBio(body: String) = ok(body)
6160

6261
override suspend fun setUserBanned(id: UUID): Response<Unit> =
6362
throw NotImplementedError()

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

+48
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package app.fyreplace.fyreplace.ui.screens
33
import androidx.compose.foundation.clickable
44
import androidx.compose.foundation.layout.Arrangement
55
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
67
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.imePadding
9+
import androidx.compose.foundation.layout.padding
710
import androidx.compose.foundation.rememberScrollState
811
import androidx.compose.foundation.verticalScroll
912
import androidx.compose.material.icons.Icons
@@ -12,22 +15,27 @@ import androidx.compose.material.icons.outlined.Code
1215
import androidx.compose.material.icons.outlined.Info
1316
import androidx.compose.material.icons.outlined.Lock
1417
import androidx.compose.material.icons.outlined.Shield
18+
import androidx.compose.material3.Button
1519
import androidx.compose.material3.Icon
1620
import androidx.compose.material3.ListItem
1721
import androidx.compose.material3.Text
22+
import androidx.compose.material3.TextField
1823
import androidx.compose.runtime.Composable
1924
import androidx.compose.runtime.getValue
2025
import androidx.compose.ui.Modifier
2126
import androidx.compose.ui.res.dimensionResource
27+
import androidx.compose.ui.res.integerResource
2228
import androidx.compose.ui.res.stringResource
2329
import androidx.compose.ui.tooling.preview.Preview
2430
import androidx.hilt.navigation.compose.hiltViewModel
2531
import androidx.lifecycle.SavedStateHandle
2632
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2733
import app.fyreplace.api.data.User
2834
import app.fyreplace.fyreplace.R
35+
import app.fyreplace.fyreplace.extensions.codePointCount
2936
import app.fyreplace.fyreplace.fakes.FakeApiResolver
3037
import app.fyreplace.fyreplace.fakes.FakeEventBus
38+
import app.fyreplace.fyreplace.fakes.FakeResourceResolver
3139
import app.fyreplace.fyreplace.fakes.FakeStoreResolver
3240
import app.fyreplace.fyreplace.fakes.placeholder
3341
import app.fyreplace.fyreplace.ui.theme.AppTheme
@@ -43,8 +51,11 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
4351
modifier = Modifier
4452
.fillMaxWidth()
4553
.verticalScroll(rememberScrollState())
54+
.imePadding()
4655
) {
4756
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
57+
val bio by viewModel.bio.collectAsStateWithLifecycle()
58+
val canUpdateBio by viewModel.canUpdateBio.collectAsStateWithLifecycle()
4859
val isLoadingAvatar by viewModel.isLoadingAvatar.collectAsStateWithLifecycle()
4960

5061
Section(stringResource(R.string.settings_profile_header)) {
@@ -64,6 +75,41 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
6475
)
6576
}
6677

78+
Section(stringResource(R.string.settings_bio_header)) {
79+
TextField(
80+
value = bio,
81+
maxLines = 10,
82+
placeholder = { Text(stringResource(R.string.settings_bio_placeholder)) },
83+
supportingText = {
84+
Text(
85+
stringResource(
86+
R.string.settings_bio_length,
87+
bio.codePointCount,
88+
integerResource(R.integer.bio_max_length)
89+
)
90+
)
91+
},
92+
onValueChange = viewModel::updatePendingBio,
93+
modifier = Modifier
94+
.fillMaxWidth()
95+
.padding(horizontal = dimensionResource(R.dimen.spacing_medium))
96+
)
97+
98+
Row(
99+
horizontalArrangement = Arrangement.Center,
100+
modifier = Modifier
101+
.fillMaxWidth()
102+
.padding(top = dimensionResource(R.dimen.spacing_small))
103+
) {
104+
Button(
105+
enabled = canUpdateBio,
106+
onClick = viewModel::updateBio
107+
) {
108+
Text(stringResource(R.string.settings_bio_save))
109+
}
110+
}
111+
}
112+
67113
Section(stringResource(R.string.settings_about_header)) {
68114
LinkListItem(
69115
title = stringResource(R.string.settings_about_website),
@@ -100,8 +146,10 @@ fun SettingsScreenPreview() {
100146
viewModel = SettingsViewModel(
101147
SavedStateHandle().apply {
102148
this[SettingsViewModel::currentUser.name] = User.placeholder
149+
this[SettingsViewModel::bio.name] = User.placeholder.bio
103150
},
104151
FakeEventBus(),
152+
FakeResourceResolver(mapOf(R.integer.bio_max_length to 100)),
105153
FakeStoreResolver(),
106154
FakeApiResolver()
107155
)

app/src/main/kotlin/app/fyreplace/fyreplace/viewmodels/screens/SettingsViewModel.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import androidx.lifecycle.viewModelScope
55
import app.fyreplace.api.data.User
66
import app.fyreplace.fyreplace.R
77
import app.fyreplace.fyreplace.api.ApiResolver
8+
import app.fyreplace.fyreplace.data.ResourceResolver
89
import app.fyreplace.fyreplace.data.StoreResolver
910
import app.fyreplace.fyreplace.events.Event
1011
import app.fyreplace.fyreplace.events.EventBus
12+
import app.fyreplace.fyreplace.extensions.codePointCount
1113
import app.fyreplace.fyreplace.extensions.update
1214
import app.fyreplace.fyreplace.protos.Connection
1315
import app.fyreplace.fyreplace.protos.Secrets
1416
import app.fyreplace.fyreplace.viewmodels.ApiViewModelBase
1517
import dagger.hilt.android.lifecycle.HiltViewModel
1618
import kotlinx.coroutines.flow.StateFlow
19+
import kotlinx.coroutines.flow.combine
1720
import kotlinx.coroutines.flow.distinctUntilChanged
1821
import kotlinx.coroutines.flow.filter
1922
import kotlinx.coroutines.flow.map
@@ -25,13 +28,21 @@ import javax.inject.Inject
2528
class SettingsViewModel @Inject constructor(
2629
private val state: SavedStateHandle,
2730
eventBus: EventBus,
31+
resourceResolver: ResourceResolver,
2832
storeResolver: StoreResolver,
2933
private val apiResolver: ApiResolver,
3034
) : ApiViewModelBase(eventBus, storeResolver) {
3135
val currentUser: StateFlow<User?> =
3236
state.getStateFlow(::currentUser.name, null)
37+
val bio: StateFlow<String> =
38+
state.getStateFlow(::bio.name, "")
3339
val isLoadingAvatar: StateFlow<Boolean> =
3440
state.getStateFlow(::isLoadingAvatar.name, false)
41+
val canUpdateBio = bio
42+
.combine(currentUser) { bio, currentUser ->
43+
bio != currentUser?.bio.orEmpty() && bio.codePointCount <= resourceResolver.getInteger(R.integer.bio_max_length)
44+
}
45+
.asState(false)
3546

3647
init {
3748
viewModelScope.launch {
@@ -42,6 +53,7 @@ class SettingsViewModel @Inject constructor(
4253
.collect {
4354
call(apiResolver::users) {
4455
state[::currentUser.name] = getCurrentUser().require()
56+
state[::bio.name] = currentUser.value?.bio.orEmpty()
4557
}
4658
}
4759
}
@@ -74,10 +86,19 @@ class SettingsViewModel @Inject constructor(
7486
}
7587

7688
fun removeAvatar() = call(apiResolver::users) {
77-
deleteCurrentUserAvatar().require()
89+
deleteCurrentUserAvatar().require() ?: return@call
7890
state[::currentUser.name] = currentUser.value?.copy(avatar = "")
7991
}
8092

93+
fun updatePendingBio(bio: String) {
94+
state[::bio.name] = bio
95+
}
96+
97+
fun updateBio() = call(apiResolver::users) {
98+
state[::bio.name] = setCurrentUserBio(bio.value).require() ?: return@call
99+
state[::currentUser.name] = currentUser.value?.copy(bio = bio.value)
100+
}
101+
81102
fun logout() {
82103
viewModelScope.launch {
83104
storeResolver.connectionStore.update(Connection.Builder::clear)

app/src/main/res/values/limits.xml

+1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
<integer name="email_min_length">3</integer>
66
<integer name="email_max_length">254</integer>
77
<integer name="random_code_min_length">8</integer>
8+
<integer name="bio_max_length">3000</integer>
89
</resources>

app/src/main/res/values/strings.xml

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<string name="settings_profile_avatar_remove">Remove avatar</string>
2323
<string name="settings_profile_logout">Logout</string>
2424
<string name="settings_profile_logout_summary">Disconnect from this account</string>
25+
<string name="settings_bio_header">Bio</string>
26+
<string name="settings_bio_placeholder">Tell the community about yourself</string>
27+
<string name="settings_bio_length">%1$d/%2$d</string>
28+
<string name="settings_bio_save">Save</string>
2529
<string name="settings_about_header">About</string>
2630
<string name="settings_about_website">Website</string>
2731
<string name="settings_about_terms_of_service">Terms of service</string>

app/src/test/kotlin/app/fyreplace/fyreplace/test/screens/SettingsViewModelTests.kt

+61-9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package app.fyreplace.fyreplace.test.screens
22

33
import androidx.lifecycle.SavedStateHandle
4+
import app.fyreplace.fyreplace.R
45
import app.fyreplace.fyreplace.events.Event
56
import app.fyreplace.fyreplace.events.EventBus
67
import app.fyreplace.fyreplace.fakes.FakeApiResolver
78
import app.fyreplace.fyreplace.fakes.FakeEventBus
9+
import app.fyreplace.fyreplace.fakes.FakeResourceResolver
810
import app.fyreplace.fyreplace.fakes.FakeSecretsHandler
911
import app.fyreplace.fyreplace.fakes.FakeStoreResolver
1012
import app.fyreplace.fyreplace.fakes.api.FakeTokensEndpointApi
@@ -18,7 +20,9 @@ import kotlinx.coroutines.launch
1820
import kotlinx.coroutines.test.runCurrent
1921
import kotlinx.coroutines.test.runTest
2022
import org.junit.Assert.assertEquals
23+
import org.junit.Assert.assertFalse
2124
import org.junit.Assert.assertNotNull
25+
import org.junit.Assert.assertTrue
2226
import org.junit.Test
2327

2428
@OptIn(ExperimentalCoroutinesApi::class)
@@ -89,13 +93,61 @@ class SettingsViewModelTests : TestsBase() {
8993
assertEquals("", viewModel.currentUser.value?.avatar)
9094
}
9195

92-
private suspend fun makeViewModel(eventBus: EventBus) = SettingsViewModel(
93-
SavedStateHandle(),
94-
eventBus,
95-
FakeStoreResolver(
96-
secrets = Secrets.newBuilder()
97-
.setToken(FakeSecretsHandler().encrypt(FakeTokensEndpointApi.TOKEN)).build()
98-
),
99-
FakeApiResolver()
100-
)
96+
@Test
97+
fun `Bio must have correct length`() = runTest {
98+
val maxLength = 30
99+
val viewModel = makeViewModel(FakeEventBus(), maxLength)
100+
runCurrent()
101+
backgroundScope.launch { viewModel.canUpdateBio.collect() }
102+
103+
viewModel.updatePendingBio("a")
104+
runCurrent()
105+
assertTrue(viewModel.canUpdateBio.value)
106+
viewModel.updatePendingBio("a".repeat(maxLength))
107+
runCurrent()
108+
assertTrue(viewModel.canUpdateBio.value)
109+
viewModel.updatePendingBio("a".repeat(maxLength + 1))
110+
runCurrent()
111+
assertFalse(viewModel.canUpdateBio.value)
112+
}
113+
114+
@Test
115+
fun `Bio must be different`() = runTest {
116+
val viewModel = makeViewModel(FakeEventBus())
117+
runCurrent()
118+
backgroundScope.launch { viewModel.canUpdateBio.collect() }
119+
120+
viewModel.updatePendingBio("Hello")
121+
runCurrent()
122+
assertTrue(viewModel.canUpdateBio.value)
123+
viewModel.updateBio()
124+
runCurrent()
125+
assertFalse(viewModel.canUpdateBio.value)
126+
}
127+
128+
@Test
129+
fun `Updating bio produces no failures`() = runTest {
130+
val eventBus = FakeEventBus()
131+
val viewModel = makeViewModel(eventBus)
132+
runCurrent()
133+
backgroundScope.launch { viewModel.currentUser.collect() }
134+
135+
viewModel.updatePendingBio("Hello")
136+
viewModel.updateBio()
137+
runCurrent()
138+
assertEquals(0, eventBus.storedEvents.filterIsInstance<Event.Failure>().count())
139+
assertEquals("Hello", viewModel.currentUser.value?.bio)
140+
}
141+
142+
private suspend fun makeViewModel(eventBus: EventBus, bioMaxLength: Int = 100) =
143+
SettingsViewModel(
144+
SavedStateHandle(),
145+
eventBus,
146+
FakeResourceResolver(mapOf(R.integer.bio_max_length to bioMaxLength)),
147+
FakeStoreResolver(
148+
secrets = Secrets.newBuilder()
149+
.setToken(FakeSecretsHandler().encrypt(FakeTokensEndpointApi.TOKEN)).build()
150+
),
151+
FakeApiResolver()
152+
)
101153
}

0 commit comments

Comments
 (0)