Skip to content

Commit 03c676d

Browse files
authored
Merge pull request #64 from Automattic/hamorillo/4-image-picker
SDK - Component to pick, edit and upload avatar image
2 parents 2df5c23 + f7e603b commit 03c676d

11 files changed

Lines changed: 341 additions & 28 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dependencies {
6363
implementation("androidx.compose.ui:ui-graphics")
6464
implementation("androidx.compose.ui:ui-tooling-preview")
6565
implementation("androidx.compose.material3:material3")
66+
implementation("androidx.compose.material:material-icons-extended")
6667
implementation(project(":gravatar"))
6768
implementation("io.coil-kt:coil-compose:2.5.0")
6869

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.gravatar.demoapp.ui
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.layout.size
9+
import androidx.compose.material.icons.Icons
10+
import androidx.compose.material.icons.rounded.AccountCircle
11+
import androidx.compose.material3.CircularProgressIndicator
12+
import androidx.compose.material3.Icon
13+
import androidx.compose.material3.MaterialTheme
14+
import androidx.compose.material3.Text
15+
import androidx.compose.runtime.Composable
16+
import androidx.compose.runtime.getValue
17+
import androidx.compose.runtime.mutableStateOf
18+
import androidx.compose.runtime.remember
19+
import androidx.compose.runtime.saveable.rememberSaveable
20+
import androidx.compose.runtime.setValue
21+
import androidx.compose.ui.Alignment
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.platform.LocalContext
24+
import androidx.compose.ui.res.stringResource
25+
import androidx.compose.ui.tooling.preview.Preview
26+
import androidx.compose.ui.unit.dp
27+
import com.gravatar.GravatarApi
28+
import com.gravatar.R
29+
import com.gravatar.demoapp.ui.components.GravatarEmailInput
30+
import com.gravatar.demoapp.ui.components.GravatarPasswordInput
31+
import com.gravatar.ui.GravatarImagePickerWrapper
32+
import com.gravatar.ui.GravatarImagePickerWrapperListener
33+
34+
@Composable
35+
fun AvatarUpdateTab(showSnackBar: (String?, Throwable?) -> Unit, modifier: Modifier = Modifier) {
36+
var email by remember { mutableStateOf("gravatar@automattic.com") }
37+
var accessToken by remember { mutableStateOf("") }
38+
var accessTokenVisible by rememberSaveable { mutableStateOf(false) }
39+
var isUploading by remember { mutableStateOf(false) }
40+
41+
Column(
42+
modifier = modifier
43+
.fillMaxSize()
44+
.background(MaterialTheme.colorScheme.background)
45+
.padding(16.dp),
46+
horizontalAlignment = Alignment.CenterHorizontally,
47+
) {
48+
val context = LocalContext.current
49+
GravatarEmailInput(email = email, onValueChange = { email = it }, Modifier.fillMaxWidth())
50+
GravatarPasswordInput(
51+
password = accessToken,
52+
passwordIsVisible = accessTokenVisible,
53+
onValueChange = { accessToken = it },
54+
onVisibilityChange = { accessTokenVisible = it },
55+
label = { Text(stringResource(R.string.access_token_label)) },
56+
modifier = Modifier
57+
.padding(top = 16.dp)
58+
.fillMaxWidth(),
59+
)
60+
GravatarImagePickerWrapper(
61+
{ UpdateAvatarComposable(isUploading) },
62+
email,
63+
accessToken,
64+
object : GravatarImagePickerWrapperListener {
65+
override fun onAvatarUploadStarted() {
66+
isUploading = true
67+
}
68+
69+
override fun onSuccess(response: Unit) {
70+
isUploading = false
71+
showSnackBar(context.getString(R.string.avatar_update_upload_success_toast), null)
72+
}
73+
74+
override fun onError(errorType: GravatarApi.ErrorType) {
75+
isUploading = false
76+
showSnackBar(context.getString(R.string.avatar_update_upload_failed_toast, errorType), null)
77+
}
78+
},
79+
modifier = Modifier.padding(top = 16.dp),
80+
)
81+
}
82+
}
83+
84+
@Composable
85+
private fun UpdateAvatarComposable(isUploading: Boolean) {
86+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
87+
if (isUploading) {
88+
CircularProgressIndicator()
89+
} else {
90+
Icon(
91+
Icons.Rounded.AccountCircle,
92+
contentDescription = "",
93+
modifier = Modifier.size(128.dp),
94+
)
95+
Text(text = stringResource(R.string.update_avatar_button_label))
96+
}
97+
}
98+
}
99+
100+
@Preview
101+
@Composable
102+
private fun UpdateAvatarComposablePreview() = UpdateAvatarComposable(false)
103+
104+
@Preview
105+
@Composable
106+
private fun UpdateAvatarLoadingComposablePreview() = UpdateAvatarComposable(true)
107+
108+
@Preview
109+
@Composable
110+
private fun AvatarUpdateTabPreview() = AvatarUpdateTab(showSnackBar = { _, _ -> })

app/src/main/java/com/gravatar/demoapp/ui/DemoGravatarApp.kt

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import androidx.compose.material3.Surface
2121
import androidx.compose.material3.Tab
2222
import androidx.compose.material3.TabRow
2323
import androidx.compose.material3.Text
24-
import androidx.compose.material3.TextField
2524
import androidx.compose.runtime.Composable
2625
import androidx.compose.runtime.getValue
2726
import androidx.compose.runtime.mutableStateOf
@@ -47,6 +46,7 @@ import com.gravatar.GravatarApi
4746
import com.gravatar.ImageRating
4847
import com.gravatar.R
4948
import com.gravatar.demoapp.theme.GravatarDemoAppTheme
49+
import com.gravatar.demoapp.ui.components.GravatarEmailInput
5050
import com.gravatar.demoapp.ui.components.ProfileCard
5151
import com.gravatar.demoapp.ui.model.SettingsState
5252
import com.gravatar.emailAddressToGravatarUrl
@@ -66,13 +66,12 @@ fun DemoGravatarApp() {
6666
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
6767
) { innerPadding ->
6868
val defaultErrorMessage = stringResource(R.string.snackbar_unknown_error_message)
69-
7069
GravatarTabs(
7170
modifier = Modifier.padding(innerPadding),
7271
gravatarUrl,
7372
{ gravatarUrl = it },
74-
) { errorMessage, exception ->
75-
onError(scope, snackbarHostState, errorMessage, exception, defaultErrorMessage)
73+
) { message, exception ->
74+
showSnackBar(scope, snackbarHostState, message, exception, defaultErrorMessage)
7675
}
7776
}
7877
}
@@ -94,17 +93,17 @@ val defaultAvatarImages by lazy {
9493
)
9594
}
9695

97-
private fun onError(
96+
private fun showSnackBar(
9897
scope: CoroutineScope,
9998
snackbarHostState: SnackbarHostState,
100-
errorMessage: String?,
99+
message: String?,
101100
throwable: Throwable?,
102-
defaultErrorMessage: String,
101+
defaultMessage: String,
103102
) {
104-
Log.e("DemoGravatarApp", "${errorMessage.orEmpty()}\n${throwable?.stackTraceToString().orEmpty()}")
103+
Log.e("DemoGravatarApp", "${message.orEmpty()}\n${throwable?.stackTraceToString().orEmpty()}")
105104
scope.launch {
106105
snackbarHostState.showSnackbar(
107-
message = errorMessage ?: throwable?.message ?: defaultErrorMessage,
106+
message = message ?: throwable?.message ?: defaultMessage,
108107
duration = SnackbarDuration.Short,
109108
)
110109
}
@@ -115,11 +114,15 @@ private fun GravatarTabs(
115114
modifier: Modifier = Modifier,
116115
gravatarUrl: String,
117116
onGravatarUrlChanged: (String) -> Unit,
118-
onError: (String?, Throwable?) -> Unit,
117+
showSnackBar: (String?, Throwable?) -> Unit,
119118
) {
120119
var tabIndex by remember { mutableStateOf(0) }
121120

122-
val tabs = listOf(stringResource(R.string.tab_label_avatar), stringResource(R.string.tab_label_profile))
121+
val tabs = listOf(
122+
stringResource(R.string.tab_label_avatar),
123+
stringResource(R.string.tab_label_profile),
124+
stringResource(R.string.tab_label_avatar_update),
125+
)
123126

124127
Column(modifier = Modifier.fillMaxSize()) {
125128
TabRow(selectedTabIndex = tabIndex) {
@@ -132,8 +135,9 @@ private fun GravatarTabs(
132135
}
133136
}
134137
when (tabIndex) {
135-
0 -> AvatarTab(modifier, gravatarUrl, onGravatarUrlChanged, onError)
136-
1 -> ProfileTab(modifier, onError)
138+
0 -> AvatarTab(modifier, gravatarUrl, onGravatarUrlChanged, showSnackBar)
139+
1 -> ProfileTab(modifier, showSnackBar)
140+
2 -> AvatarUpdateTab(showSnackBar, modifier)
137141
}
138142
}
139143
}
@@ -194,9 +198,13 @@ private fun ProfileTab(modifier: Modifier = Modifier, onError: (String?, Throwab
194198
if (!loading && error.isEmpty() && profiles.entry.size > 0) {
195199
ProfileCard(
196200
profiles.entry.first(),
197-
Modifier.clip(
198-
RoundedCornerShape(8.dp),
199-
).background(MaterialTheme.colorScheme.surfaceContainer).fillMaxWidth().padding(16.dp),
201+
Modifier
202+
.clip(
203+
RoundedCornerShape(8.dp),
204+
)
205+
.background(MaterialTheme.colorScheme.surfaceContainer)
206+
.fillMaxWidth()
207+
.padding(16.dp),
200208
)
201209
} else {
202210
if (error.isNotEmpty()) {
@@ -327,14 +335,3 @@ fun GravatarImage(gravatarUrl: String, onError: (String?, Throwable?) -> Unit) {
327335
contentDescription = "",
328336
)
329337
}
330-
331-
@Composable
332-
fun GravatarEmailInput(email: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
333-
TextField(
334-
value = email,
335-
onValueChange = onValueChange,
336-
label = { Text(stringResource(R.string.gravatar_email_input_label)) },
337-
maxLines = 1,
338-
modifier = modifier,
339-
)
340-
}

app/src/main/java/com/gravatar/demoapp/ui/GravatarImageSettings.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.gravatar.DefaultAvatarImage
2020
import com.gravatar.ImageRating
2121
import com.gravatar.R
2222
import com.gravatar.demoapp.theme.GravatarDemoAppTheme
23+
import com.gravatar.demoapp.ui.components.GravatarEmailInput
2324
import com.gravatar.demoapp.ui.model.SettingsState
2425

2526
@Composable
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.gravatar.demoapp.ui.components
2+
3+
import androidx.compose.material3.Text
4+
import androidx.compose.material3.TextField
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.res.stringResource
8+
import com.gravatar.R
9+
10+
@Composable
11+
fun GravatarEmailInput(email: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
12+
TextField(
13+
value = email,
14+
onValueChange = onValueChange,
15+
label = { Text(stringResource(R.string.gravatar_email_input_label)) },
16+
maxLines = 1,
17+
modifier = modifier,
18+
)
19+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.gravatar.demoapp.ui.components
2+
3+
import androidx.compose.foundation.text.KeyboardOptions
4+
import androidx.compose.material.icons.Icons
5+
import androidx.compose.material.icons.filled.Visibility
6+
import androidx.compose.material.icons.filled.VisibilityOff
7+
import androidx.compose.material3.Icon
8+
import androidx.compose.material3.IconButton
9+
import androidx.compose.material3.TextField
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.text.input.KeyboardType
13+
import androidx.compose.ui.text.input.PasswordVisualTransformation
14+
import androidx.compose.ui.text.input.VisualTransformation
15+
16+
@Composable
17+
fun GravatarPasswordInput(
18+
password: String,
19+
passwordIsVisible: Boolean,
20+
onValueChange: (String) -> Unit,
21+
onVisibilityChange: (Boolean) -> Unit,
22+
label: @Composable (() -> Unit),
23+
modifier: Modifier = Modifier,
24+
) {
25+
TextField(
26+
value = password,
27+
onValueChange = onValueChange,
28+
label = label,
29+
maxLines = 1,
30+
modifier = modifier,
31+
visualTransformation = if (passwordIsVisible) VisualTransformation.None else PasswordVisualTransformation(),
32+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
33+
trailingIcon = {
34+
val image = if (passwordIsVisible) {
35+
Icons.Filled.Visibility
36+
} else {
37+
Icons.Filled.VisibilityOff
38+
}
39+
40+
IconButton(onClick = { onVisibilityChange(!passwordIsVisible) }) {
41+
Icon(imageVector = image, "")
42+
}
43+
},
44+
)
45+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
<string name="snackbar_unknown_error_message">Unknown error</string>
1111
<string name="tab_label_profile">Profile</string>
1212
<string name="tab_label_avatar">Avatar</string>
13+
<string name="tab_label_avatar_update">Avatar Update</string>
1314
<string name="button_get_profile">Get Profile</string>
1415
<string name="text_display_name">Display Name: %1$s</string>
1516
<string name="text_url">Url: %1$s</string>
1617
<string name="button_load_gravatar">Load Gravatar</string>
18+
<string name="access_token_label">Access Token</string>
19+
<string name="update_avatar_button_label">Update Avatar</string>
20+
<string name="avatar_update_upload_success_toast">Upload success</string>
21+
<string name="avatar_update_upload_failed_toast">Upload error: %1$s</string>
1722
</resources>

gravatar/build.gradle.kts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ android {
3939
kotlinOptions {
4040
jvmTarget = "1.8"
4141
}
42-
42+
buildFeatures {
43+
compose = true
44+
}
45+
composeOptions {
46+
kotlinCompilerExtensionVersion = "1.5.8"
47+
}
4348
detekt {
4449
config.setFrom("${project.rootDir}/config/detekt/detekt.yml")
4550
source.setFrom("src")
@@ -70,6 +75,15 @@ dependencies {
7075
implementation("com.squareup.retrofit2:retrofit:2.9.0")
7176
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
7277
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
78+
implementation("com.github.yalantis:ucrop:2.2.8")
79+
80+
// Jetpack Compose
81+
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
82+
implementation("androidx.activity:activity-compose:1.8.2")
83+
implementation("androidx.compose.ui:ui")
84+
implementation("androidx.compose.ui:ui-tooling-preview")
85+
implementation("androidx.compose.material3:material3")
86+
debugImplementation("androidx.compose.ui:ui-tooling:1.6.2")
7387

7488
testImplementation("junit:junit:4.13.2")
7589
testImplementation("org.robolectric:robolectric:4.11.1")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

4+
<application>
5+
<!-- Lib activities-->
6+
<activity
7+
android:name="com.yalantis.ucrop.UCropActivity"
8+
android:theme="@style/Theme.AppCompat.NoActionBar" />
9+
</application>
410
</manifest>

0 commit comments

Comments
 (0)