Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.

Commit 85bc86a

Browse files
authored
Merge pull request #76 from Automattic/hamorillo/GRA-579
Generate QR code with vCard data
2 parents 21a4bf8 + e82d6d9 commit 85bc86a

File tree

10 files changed

+655
-3
lines changed

10 files changed

+655
-3
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ ksp = "2.1.21-2.0.2"
2626
ucrop = "2.2.11"
2727
androidxTestCore = "1.6.1"
2828
constraintLayout = "1.1.0"
29+
qrose= "1.0.1"
2930

3031
[libraries]
3132
kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" }
@@ -76,6 +77,7 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
7677
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
7778
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
7879
ucrop = { group = "com.automattic", name = "ucrop", version.ref = "ucrop" }
80+
qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref = "qrose" }
7981

8082
# Dependencies of the included build-logic
8183
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }

homeUi/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929
implementation(libs.gravatar.core)
3030
implementation(libs.gravatar.ui)
3131
implementation(libs.ucrop)
32+
implementation(libs.qrose)
3233

3334
testImplementation(libs.junit)
3435
testImplementation(libs.kotlinx.coroutines.test)
57.4 KB
Loading

homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) {
5757
onAboutAppClicked = {
5858
onEvent(ShareEvent.OnAboutAppClicked)
5959
},
60+
vCardQrCodeData = uiState.vCardQrCodeData.toString(),
6061
modifier = Modifier
6162
.fillMaxWidth(),
6263
)

homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareUiState.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.gravatar.app.homeUi.presentation.home.share
22

3+
import com.gravatar.app.homeUi.presentation.home.share.model.VCard
34
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
45
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
56
import com.gravatar.restapi.models.Profile
@@ -12,7 +13,6 @@ internal data class ShareUiState(
1213
val userSharePreferences: UserSharePreferences = UserSharePreferences.Default,
1314
val isPrivateInformationDialogVisible: Boolean = false,
1415
) {
15-
1616
val privateContactState = PrivateContactState(
1717
emailValue = privateContactInfo.privateEmail,
1818
isEmailShared = userSharePreferences.privateEmail,
@@ -34,11 +34,23 @@ internal data class ShareUiState(
3434
profileUrl = if (shareFieldType is ShareFieldType.ProfileUrl) shareFieldType.checked else userSharePreferences.profileUrl,
3535
)
3636
)
37+
38+
val vCardQrCodeData: VCard = VCard.Builder()
39+
.firstName(profile?.firstName.takeIf { userSharePreferences.name })
40+
.lastName(profile?.lastName.takeIf { userSharePreferences.name })
41+
.nickname(profile?.displayName.takeIf { userSharePreferences.description })
42+
.organization(profile?.company.takeIf { userSharePreferences.organization })
43+
.title(profile?.jobTitle.takeIf { userSharePreferences.title })
44+
.profileUrl(profile?.profileUrl.toString().takeIf { userSharePreferences.profileUrl })
45+
.note(profile?.description.takeIf { userSharePreferences.description })
46+
.phoneNumber(privateContactState.phoneValue.takeIf { privateContactState.isPhoneShared })
47+
.email(privateContactState.emailValue.takeIf { privateContactState.isEmailShared })
48+
.build()
3749
}
3850

3951
internal data class PrivateContactState(
4052
val emailValue: String = "",
41-
val isEmailShared: Boolean = false,
53+
val isEmailShared: Boolean = true,
4254
val phoneValue: String = "",
4355
val isPhoneShared: Boolean = false,
4456
)

homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeader.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
2323
import androidx.compose.ui.Alignment
2424
import androidx.compose.ui.Modifier
2525
import androidx.compose.ui.graphics.Color
26+
import androidx.compose.ui.graphics.painter.Painter
2627
import androidx.compose.ui.res.painterResource
2728
import androidx.compose.ui.res.stringResource
2829
import androidx.compose.ui.tooling.preview.Preview
@@ -33,14 +34,27 @@ import com.gravatar.app.homeUi.R
3334
import com.gravatar.app.homeUi.presentation.home.components.BlurredHeaderBackground
3435
import com.gravatar.app.homeUi.presentation.home.components.topbar.TopBarPickerPopup
3536
import com.gravatar.app.homeUi.presentation.home.profile.header.MENU_BUTTON_SIZE
37+
import io.github.alexzhirkevich.qrose.options.QrBallShape
38+
import io.github.alexzhirkevich.qrose.options.QrFrameShape
39+
import io.github.alexzhirkevich.qrose.options.QrPixelShape
40+
import io.github.alexzhirkevich.qrose.options.roundCorners
41+
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
3642

3743
@Composable
3844
internal fun ShareHeader(
3945
avatarUrl: String,
46+
vCardQrCodeData: String,
4047
modifier: Modifier = Modifier,
4148
onAboutAppClicked: () -> Unit = {},
4249
) {
4350
var topBarMenuVisible by remember { mutableStateOf(false) }
51+
val qrcodePainter: Painter = rememberQrCodePainter(vCardQrCodeData) {
52+
shapes {
53+
ball = QrBallShape.roundCorners(.30f)
54+
darkPixel = QrPixelShape.roundCorners()
55+
frame = QrFrameShape.roundCorners(.15f)
56+
}
57+
}
4458

4559
BlurredHeaderBackground(
4660
avatarUrl = avatarUrl,
@@ -59,14 +73,17 @@ internal fun ShareHeader(
5973
.padding(top = 6.dp)
6074
.weight(1f),
6175
) {
62-
Box(
76+
Image(
77+
painter = qrcodePainter,
78+
contentDescription = null,
6379
modifier = Modifier
6480
.background(Color.White, RoundedCornerShape(4.dp))
6581
.fillMaxWidth()
6682
.aspectRatio(
6783
ratio = 1f,
6884
matchHeightConstraintsFirst = false,
6985
)
86+
.padding(6.dp)
7087
)
7188
Text(
7289
text = stringResource(R.string.share_tab_scan_qr_code),
@@ -111,6 +128,7 @@ private fun ShareHeaderPreview() {
111128
GravatarAppTheme {
112129
ShareHeader(
113130
avatarUrl = "url",
131+
vCardQrCodeData = "BEGIN:VCARD\nVERSION:3.0\nFN:Preview User\nEND:VCARD",
114132
modifier = Modifier
115133
.fillMaxWidth()
116134
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.gravatar.app.homeUi.presentation.home.share.model
2+
3+
internal class VCard private constructor(
4+
val firstName: String? = null,
5+
val lastName: String? = null,
6+
val nickname: String? = null,
7+
val organization: String? = null,
8+
val title: String? = null,
9+
val profileUrl: String? = null,
10+
val note: String? = null,
11+
val phoneNumber: String? = null,
12+
val email: String? = null,
13+
) {
14+
15+
override fun toString(): String {
16+
val contentBuilder = StringBuilder().append("BEGIN:VCARD\n")
17+
.append("VERSION:3.0\n")
18+
.append("PRODID:Gravatar Android\n")
19+
20+
val firstName = firstName.orEmpty()
21+
val lastName = lastName.orEmpty()
22+
if (firstName.isNotEmpty() || lastName.isNotEmpty()) {
23+
contentBuilder.append("N:${lastName.escaped()};${firstName.escaped()};;;\n")
24+
} else {
25+
nickname?.takeIf { it.isNotEmpty() }?.let {
26+
contentBuilder.append("N:;${nickname.escaped()};;;\n")
27+
}
28+
}
29+
30+
// Providing an empty FN as it is required for vCard 3.0.
31+
contentBuilder.append("FN:\n")
32+
33+
nickname?.takeIf { it.isNotEmpty() }?.let {
34+
contentBuilder
35+
.append("NICKNAME:${it.escaped()}\n")
36+
}
37+
organization?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("ORG:${it.escaped()}\n") }
38+
title?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("TITLE:${it.escaped()}\n") }
39+
profileUrl?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("URL:${it.escaped()}\n") }
40+
note?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("NOTE:${it.escaped()}\n") }
41+
phoneNumber?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("TEL;TYPE=cell:${it.escaped()}\n") }
42+
email?.takeIf { it.isNotEmpty() }?.let { contentBuilder.append("EMAIL:${it.escaped()}\n") }
43+
44+
contentBuilder.append("END:VCARD")
45+
return contentBuilder.toString()
46+
}
47+
48+
// We've seen issues with newlines in the vCard content causing problems when importing the contact so removing them
49+
private fun String.escaped() = this.replace("\n", " ")
50+
51+
class Builder(
52+
private var firstName: String? = null,
53+
private var lastName: String? = null,
54+
private var nickname: String? = null,
55+
private var organization: String? = null,
56+
private var title: String? = null,
57+
private var profileUrl: String? = null,
58+
private var note: String? = null,
59+
private var phoneNumber: String? = null,
60+
private var email: String? = null,
61+
) {
62+
fun firstName(firstName: String?) = apply { this.firstName = firstName }
63+
fun lastName(lastName: String?) = apply { this.lastName = lastName }
64+
fun nickname(nickname: String?) = apply { this.nickname = nickname }
65+
fun organization(organization: String?) = apply { this.organization = organization }
66+
fun title(title: String?) = apply { this.title = title }
67+
fun profileUrl(url: String?) = apply { this.profileUrl = url }
68+
fun note(description: String?) = apply { this.note = description }
69+
fun phoneNumber(phone: String?) = apply { this.phoneNumber = phone }
70+
fun email(email: String?) = apply { this.email = email }
71+
72+
fun build() = VCard(
73+
firstName = firstName,
74+
lastName = lastName,
75+
nickname = nickname,
76+
organization = organization,
77+
title = title,
78+
profileUrl = profileUrl,
79+
note = note,
80+
phoneNumber = phoneNumber,
81+
email = email
82+
)
83+
}
84+
}

0 commit comments

Comments
 (0)