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

Commit 6505ef7

Browse files
committed
Generate QR code with vCard data
1 parent 4b78b23 commit 6505ef7

File tree

9 files changed

+234
-6
lines changed

9 files changed

+234
-6
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)
18.9 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
@@ -45,6 +45,7 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) {
4545
) {
4646
ShareHeader(
4747
avatarUrl = uiState.avatarUrl.orEmpty(),
48+
vCardQrCodeData = uiState.vCardQrCodeData,
4849
modifier = Modifier
4950
.fillMaxWidth()
5051
)

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

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,62 @@ import com.gravatar.restapi.models.Profile
55
internal data class ShareUiState(
66
val profile: Profile? = null,
77
val avatarUrl: String? = null,
8-
val privateContactInfo: PrivateContactInfo = PrivateContactInfo()
9-
)
8+
val privateContactInfo: PrivateContactInfo = PrivateContactInfo(),
9+
) {
10+
val vCardQrCodeData: String = generateVCardData(profile, privateContactInfo)
11+
}
1012

1113
internal data class PrivateContactInfo(
1214
val emailValue: String = "",
13-
val isEmailShared: Boolean = false,
15+
val isEmailShared: Boolean = true,
1416
val phoneValue: String = "",
15-
val isPhoneShared: Boolean = false
17+
val isPhoneShared: Boolean = true,
1618
)
19+
20+
private fun generateVCardData(profile: Profile?, privateContactInfo: PrivateContactInfo): String {
21+
val vCardBuilder = StringBuilder()
22+
vCardBuilder.append("BEGIN:VCARD\n")
23+
vCardBuilder.append("VERSION:3.0\n")
24+
vCardBuilder.append("PRODID:Gravatar Android\n")
25+
26+
// Add name information if available
27+
if (profile != null) {
28+
val firstName = profile.firstName.orEmpty()
29+
val lastName = profile.lastName.orEmpty()
30+
if (firstName.isNotEmpty() || lastName.isNotEmpty()) {
31+
vCardBuilder.append("N:$lastName;$firstName;;;\n")
32+
vCardBuilder.append("FN:${("$firstName $lastName".trim()).ifEmpty { profile.displayName }}\n")
33+
vCardBuilder.append("NICKNAME:${profile.displayName.ifEmpty { "$firstName $lastName".trim() }}\n")
34+
}
35+
36+
// Add organization information if available
37+
if (profile.company.isNotEmpty()) {
38+
vCardBuilder.append("ORG:${profile.company}\n")
39+
}
40+
41+
// Add job title if available
42+
if (profile.jobTitle.isNotEmpty()) {
43+
vCardBuilder.append("TITLE:${profile.jobTitle}\n")
44+
}
45+
46+
// Add URL
47+
vCardBuilder.append("URL:${profile.profileUrl}\n")
48+
49+
// Add Note
50+
if (profile.description.isNotEmpty()) {
51+
vCardBuilder.append("NOTE:${profile.description}\n")
52+
}
53+
}
54+
55+
// Add private contact info if shared
56+
if (privateContactInfo.isPhoneShared && privateContactInfo.phoneValue.isNotEmpty()) {
57+
vCardBuilder.append("TEL;TYPE=cell:${privateContactInfo.phoneValue}\n")
58+
}
59+
60+
if (privateContactInfo.isEmailShared && privateContactInfo.emailValue.isNotEmpty()) {
61+
vCardBuilder.append("EMAIL:${privateContactInfo.emailValue}\n")
62+
}
63+
64+
vCardBuilder.append("END:VCARD")
65+
return vCardBuilder.toString()
66+
}

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

Lines changed: 9 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,13 +34,16 @@ 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.rememberQrCodePainter
3638

3739
@Composable
3840
internal fun ShareHeader(
3941
avatarUrl: String,
42+
vCardQrCodeData: String,
4043
modifier: Modifier = Modifier,
4144
) {
4245
var topBarMenuVisible by remember { mutableStateOf(false) }
46+
val qrcodePainter: Painter = rememberQrCodePainter(vCardQrCodeData)
4347

4448
BlurredHeaderBackground(
4549
avatarUrl = avatarUrl,
@@ -58,14 +62,17 @@ internal fun ShareHeader(
5862
.padding(top = 6.dp)
5963
.weight(1f),
6064
) {
61-
Box(
65+
Image(
66+
painter = qrcodePainter,
67+
contentDescription = null,
6268
modifier = Modifier
6369
.background(Color.White, RoundedCornerShape(4.dp))
6470
.fillMaxWidth()
6571
.aspectRatio(
6672
ratio = 1f,
6773
matchHeightConstraintsFirst = false,
6874
)
75+
.padding(6.dp)
6976
)
7077
Text(
7178
text = stringResource(R.string.share_tab_scan_qr_code),
@@ -106,6 +113,7 @@ private fun ShareHeaderPreview() {
106113
GravatarAppTheme {
107114
ShareHeader(
108115
avatarUrl = "url",
116+
vCardQrCodeData = "BEGIN:VCARD\nVERSION:3.0\nFN:Preview User\nEND:VCARD",
109117
modifier = Modifier
110118
.fillMaxWidth()
111119
)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.gravatar.app.homeUi.presentation.home.share
2+
3+
import com.gravatar.restapi.models.Profile
4+
import com.gravatar.restapi.models.ProfileContactInfo
5+
import org.junit.Assert.assertFalse
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
import java.net.URI
9+
10+
class ShareUiStateTest {
11+
12+
@Test
13+
fun `when ShareUiState has complete profile and shared contact info then vCardQrCodeData contains all information`() {
14+
// Given
15+
val profile = createCompleteProfile()
16+
val privateContactInfo = PrivateContactInfo(
17+
emailValue = "test@example.com",
18+
isEmailShared = true,
19+
phoneValue = "123-456-7890",
20+
isPhoneShared = true
21+
)
22+
23+
// When
24+
val shareUiState = ShareUiState(
25+
profile = profile,
26+
avatarUrl = "https://www.gravatar.com/avatar/test-hash",
27+
privateContactInfo = privateContactInfo
28+
)
29+
30+
// Then
31+
val vCardData = shareUiState.vCardQrCodeData
32+
33+
// Verify vCard structure
34+
assertTrue(vCardData.startsWith("BEGIN:VCARD"))
35+
assertTrue(vCardData.endsWith("END:VCARD"))
36+
assertTrue(vCardData.contains("VERSION:3.0"))
37+
38+
// Verify profile information
39+
assertTrue(vCardData.contains("N:User;Test;;;"))
40+
assertTrue(vCardData.contains("FN:Test User"))
41+
assertTrue(vCardData.contains("NICKNAME:Test User"))
42+
assertTrue(vCardData.contains("ORG:Test Company"))
43+
assertTrue(vCardData.contains("TITLE:Software Engineer"))
44+
assertTrue(vCardData.contains("URL:https://www.gravatar.com/test-hash"))
45+
assertTrue(vCardData.contains("NOTE:Test description"))
46+
47+
// Verify contact information
48+
assertTrue(vCardData.contains("TEL;TYPE=cell:123-456-7890"))
49+
assertTrue(vCardData.contains("EMAIL:test@example.com"))
50+
}
51+
52+
@Test
53+
fun `when ShareUiState has minimal profile data then vCardQrCodeData contains only available information`() {
54+
// Given
55+
val profile = createMinimalProfile()
56+
57+
// When
58+
val shareUiState = ShareUiState(
59+
profile = profile,
60+
avatarUrl = null,
61+
privateContactInfo = PrivateContactInfo()
62+
)
63+
64+
// Then
65+
var vCardData = shareUiState.vCardQrCodeData
66+
67+
// Verify vCard structure
68+
assertTrue(vCardData.startsWith("BEGIN:VCARD"))
69+
assertTrue(vCardData.endsWith("END:VCARD"))
70+
71+
// Remove BEGIN:VCARD and VERSION lines for easier verification
72+
vCardData = vCardData.replace("BEGIN:VCARD\n", "")
73+
.replace("END:VCARD\n", "")
74+
.replace("VERSION:3.0\n", "")
75+
76+
// Verify minimal profile information is included
77+
assertTrue(vCardData.contains("URL:https://www.gravatar.com/minimal-hash"))
78+
79+
// Verify that optional fields are not included
80+
assertFalse("vCard should not contain N:", vCardData.contains("N:"))
81+
assertFalse("vCard should not contain FN:", vCardData.contains("FN:"))
82+
assertFalse("vCard should not contain NICKNAME:", vCardData.contains("NICKNAME:"))
83+
assertFalse("vCard should not contain ORG:", vCardData.contains("ORG:"))
84+
assertFalse("vCard should not contain TITLE:", vCardData.contains("TITLE:"))
85+
assertFalse("vCard should not contain NOTE:", vCardData.contains("NOTE:"))
86+
assertFalse("vCard should not contain TEL;", vCardData.contains("TEL;"))
87+
assertFalse("vCard should not contain EMAIL:", vCardData.contains("EMAIL:"))
88+
}
89+
90+
@Test
91+
fun `when ShareUiState has unshared contact info then vCardQrCodeData does not include private contact info`() {
92+
// Given
93+
val profile = createCompleteProfile()
94+
val privateContactInfo = PrivateContactInfo(
95+
emailValue = "test@example.com",
96+
isEmailShared = false,
97+
phoneValue = "123-456-7890",
98+
isPhoneShared = false
99+
)
100+
101+
// When
102+
val shareUiState = ShareUiState(
103+
profile = profile,
104+
avatarUrl = "https://www.gravatar.com/avatar/test-hash",
105+
privateContactInfo = privateContactInfo
106+
)
107+
108+
// Then
109+
val vCardData = shareUiState.vCardQrCodeData
110+
111+
// Verify profile information is included
112+
assertTrue(vCardData.contains("N:User;Test;;;"))
113+
assertTrue(vCardData.contains("FN:Test User"))
114+
115+
// Verify private contact info is not included
116+
assertFalse("vCard should not contain TEL;", vCardData.contains("TEL;"))
117+
assertFalse("vCard should not contain EMAIL:", vCardData.contains("EMAIL:"))
118+
}
119+
120+
private fun createCompleteProfile() = Profile {
121+
hash = "test-hash"
122+
displayName = "Test User"
123+
profileUrl = URI("https://www.gravatar.com/test-hash")
124+
avatarUrl = URI("https://www.gravatar.com/avatar/test-hash")
125+
avatarAltText = "Avatar for Test User"
126+
description = "Test description"
127+
pronouns = "They/Them"
128+
pronunciation = "Test pronunciation"
129+
location = "Test Location"
130+
jobTitle = "Software Engineer"
131+
company = "Test Company"
132+
firstName = "Test"
133+
lastName = "User"
134+
verifiedAccounts = emptyList()
135+
contactInfo = ProfileContactInfo {
136+
cellPhone = "123-456-7890"
137+
email = "test@example.com"
138+
}
139+
}
140+
141+
private fun createMinimalProfile() = Profile {
142+
hash = "minimal-hash"
143+
displayName = ""
144+
profileUrl = URI("https://www.gravatar.com/minimal-hash")
145+
avatarUrl = URI("https://www.gravatar.com/avatar/minimal-hash")
146+
avatarAltText = ""
147+
description = ""
148+
pronouns = ""
149+
pronunciation = ""
150+
location = ""
151+
jobTitle = ""
152+
company = ""
153+
firstName = ""
154+
lastName = ""
155+
verifiedAccounts = emptyList()
156+
contactInfo = ProfileContactInfo {
157+
cellPhone = ""
158+
email = ""
159+
}
160+
}
161+
}

homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package com.gravatar.app.homeUi.presentation.home.share
33
import app.cash.turbine.test
44
import com.gravatar.app.testUtils.CoroutineTestRule
55
import com.gravatar.app.usercomponent.domain.repository.UserRepository
6+
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
67
import com.gravatar.restapi.models.Profile
78
import com.gravatar.restapi.models.ProfileContactInfo
89
import io.mockk.every
910
import io.mockk.mockk
1011
import kotlinx.coroutines.ExperimentalCoroutinesApi
1112
import kotlinx.coroutines.flow.MutableSharedFlow
13+
import kotlinx.coroutines.flow.flowOf
1214
import kotlinx.coroutines.test.StandardTestDispatcher
1315
import kotlinx.coroutines.test.advanceUntilIdle
1416
import kotlinx.coroutines.test.runTest
@@ -26,14 +28,16 @@ class ShareViewModelTest {
2628
var coroutineTestRule = CoroutineTestRule(testDispatcher)
2729

2830
private val userRepository = mockk<UserRepository>()
31+
private val getAvatarUrl = mockk<GetAvatarUrl>()
2932
private lateinit var viewModel: ShareViewModel
3033

3134
private val profileFlow: MutableSharedFlow<Profile?> = MutableSharedFlow()
3235

3336
@Before
3437
fun setup() {
3538
every { userRepository.getProfile() } returns profileFlow
36-
viewModel = ShareViewModel(userRepository)
39+
every { getAvatarUrl.invoke() } returns flowOf(null)
40+
viewModel = ShareViewModel(userRepository, getAvatarUrl)
3741
}
3842

3943
@Test

homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/components/ShareHeaderTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class ShareHeaderTest : RoborazziTest() {
1313
GravatarAppTheme {
1414
ShareHeader(
1515
avatarUrl = "url",
16+
vCardQrCodeData = "BEGIN:VCARD\nVERSION:3.0\nFN:Test User\nEND:VCARD",
1617
modifier = Modifier.fillMaxWidth()
1718
)
1819
}

0 commit comments

Comments
 (0)