Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,14 @@ internal class GravatarFileProvider : FileProvider(R.xml.gravatar_filepaths) {
file,
)
}

fun getFileUri(context: Context, file: File): Uri {
val authority = "${context.packageName}.fileprovider"
return getUriForFile(
context,
authority,
file,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.gravatar.app.homeUi.di

import com.gravatar.app.homeUi.ImageDownloader
import com.gravatar.app.homeUi.presentation.DrawableUtils
import com.gravatar.app.homeUi.presentation.FileUtils
import com.gravatar.app.homeUi.presentation.home.HomeViewModel
import com.gravatar.app.homeUi.presentation.home.components.topbar.TopBarPickerPopupViewModel
Expand All @@ -16,6 +17,7 @@ import org.koin.dsl.module
val homeUiModule = module {
factory<ImageDownloader> { ImageDownloader(androidContext()) }
factoryOf(::FileUtils)
factoryOf(::DrawableUtils)
viewModelOf(::ProfileViewModel)
viewModelOf(::GravatarViewModel)
viewModelOf(::TopBarPickerPopupViewModel)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.gravatar.app.homeUi.presentation

import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import java.io.ByteArrayOutputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

internal class DrawableUtils(private val context: Context) {

private val imageLoader = ImageLoader(context)

suspend fun downloadDrawable(url: String): Drawable? {
return try {
val request = ImageRequest.Builder(context)
.data(url)
.build()
imageLoader.execute(request).drawable
} catch (_: Exception) {
null
}
}
}

/**
* Converts a Drawable to a Base64 encoded string.
*
* @param drawable The Drawable to convert.
* @param format The desired image format (Bitmap.CompressFormat.PNG or Bitmap.CompressFormat.JPEG).
* @param quality The quality for JPEG compression (0-100).
* @return The Base64 encoded string representation of the image, or null if conversion fails.
*/
@OptIn(ExperimentalEncodingApi::class)
@Suppress("TooGenericExceptionCaught")
internal fun drawableToBase64(
drawable: Drawable,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
quality: Int = 30
): Result<String> {
val bitmap = drawable.toBitmap()

return try {
val base64String = ByteArrayOutputStream().use { outputStream ->
bitmap.compress(format, quality, outputStream)
Base64.encode(outputStream.toByteArray())
}
Result.success(base64String)
} catch (e: Exception) {
Result.failure(e)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import java.io.File
internal class FileUtils(
private val context: Context,
) {
fun createVCardFile(fileNameWithoutExtension: String, content: String): File {
val directory = File(context.cacheDir, "vcard")
directory.mkdirs()
return File(
directory,
"${fileNameWithoutExtension.ifEmpty { "vcard_${System.currentTimeMillis()}" }}.vcf"
).apply {
writeText(content)
}
}

fun createCroppedAvatarFile(): File {
return File(context.cacheDir, "cropped_avatar_${System.currentTimeMillis()}.jpg")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gravatar.app.homeUi.presentation.home.share

import java.io.File

internal sealed class ShareAction {
data class ShareVCard(val vCardFile: File) : ShareAction()
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ internal sealed class ShareEvent {
data object OnDismissAboutAppDialog : ShareEvent()
data object OnPrivateInformationClicked : ShareEvent()
data object OnDismissPrivateInformationDialog : ShareEvent()
data object OnShareClick : ShareEvent()
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.gravatar.app.homeUi.presentation.home.share

import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand All @@ -10,21 +12,30 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.gravatar.app.design.theme.GravatarAppTheme
import com.gravatar.app.homeUi.GravatarFileProvider
import com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialog
import com.gravatar.app.homeUi.presentation.home.share.components.ItemDivider
import com.gravatar.app.homeUi.presentation.home.share.components.PrivateInformationDialog
import com.gravatar.app.homeUi.presentation.home.share.components.ShareHeader
import com.gravatar.app.homeUi.presentation.home.share.components.SharePrivateContactInfo
import com.gravatar.app.homeUi.presentation.home.share.components.SharePublicContactInfo
import com.gravatar.extensions.defaultProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
import java.io.File

@Suppress("UnusedParameter")
@Composable
Expand All @@ -33,8 +44,24 @@ internal fun ShareScreen(
viewModel: ShareViewModel = koinViewModel(viewModelStoreOwner = viewModelStoreOwner),
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsState()

LaunchedEffect(Unit) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.actions.collect { action ->
when (action) {
is ShareAction.ShareVCard -> {
shareVCardFile(action.vCardFile, context)
}
}
}
}
}
}

ShareScreen(
uiState = uiState,
onEvent = { event ->
Expand All @@ -57,7 +84,8 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) {
onAboutAppClicked = {
onEvent(ShareEvent.OnAboutAppClicked)
},
vCardQrCodeData = uiState.vCardQrCodeData.toString(),
vCardQrCodeData = uiState.vCardQrCodeData.exportToString(withPhoto = false),
onShareClick = { onEvent(ShareEvent.OnShareClick) },
modifier = Modifier
.fillMaxWidth(),
)
Expand Down Expand Up @@ -107,6 +135,21 @@ internal fun ShareScreen(uiState: ShareUiState, onEvent: (ShareEvent) -> Unit) {
}
}

private fun shareVCardFile(
vCardFile: File,
context: Context,
) {
val vCardFileUri = GravatarFileProvider.getFileUri(context, vCardFile)

val intentShareFile = Intent(Intent.ACTION_SEND)

// Use the correct MIME type for vCard files
intentShareFile.type = "text/vcard"
intentShareFile.putExtra(Intent.EXTRA_STREAM, vCardFileUri)

context.startActivity(Intent.createChooser(intentShareFile, null))
}

@Preview
@Composable
private fun ShareScreenPreview() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.gravatar.app.homeUi.presentation.home.share

import android.graphics.drawable.Drawable
import com.gravatar.app.homeUi.presentation.home.share.model.VCard
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
Expand All @@ -12,6 +13,7 @@ internal data class ShareUiState(
val privateContactInfo: PrivateContactInfo = PrivateContactInfo.Default,
val userSharePreferences: UserSharePreferences = UserSharePreferences.Default,
val isPrivateInformationDialogVisible: Boolean = false,
private val avatarDrawable: Drawable? = null,
) {
val privateContactState = PrivateContactState(
emailValue = privateContactInfo.privateEmail,
Expand Down Expand Up @@ -45,6 +47,7 @@ internal data class ShareUiState(
.note(profile?.description.takeIf { userSharePreferences.description })
.phoneNumber(privateContactState.phoneValue.takeIf { privateContactState.isPhoneShared })
.email(privateContactState.emailValue.takeIf { privateContactState.isEmailShared })
.photo(avatarDrawable)
.build()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,31 @@ package com.gravatar.app.homeUi.presentation.home.share

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.gravatar.app.homeUi.presentation.DrawableUtils
import com.gravatar.app.homeUi.presentation.FileUtils
import com.gravatar.app.usercomponent.domain.facade.PrivateContactInfoFacade
import com.gravatar.app.usercomponent.domain.facade.UserSharePreferencesFacade
import com.gravatar.app.usercomponent.domain.repository.UserRepository
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

internal class ShareViewModel(
private val userRepository: UserRepository,
private val getAvatarUrl: GetAvatarUrl,
private val getUserSharePreferences: GetUserSharePreferences,
private val updateUserSharePreferences: UpdateUserSharePreferences,
private val getPrivateContactInfo: GetPrivateContactInfo,
private val updatePrivateContactInfo: UpdatePrivateContactInfo,
private val sharePreferencesFacade: UserSharePreferencesFacade,
private val privateContactInfoFacade: PrivateContactInfoFacade,
private val drawableUtils: DrawableUtils,
private val fileUtils: FileUtils,
) : ViewModel() {

private val _uiState = MutableStateFlow(ShareUiState())
Expand All @@ -33,6 +35,9 @@ internal class ShareViewModel(
private var saveContactInfoJob: Job? = null
private val debounceDelay = 500L // 500ms debounce delay

private val _actions = Channel<ShareAction>(Channel.BUFFERED)
val actions = _actions.receiveAsFlow()

init {
collectProfile()
collectAvatarUrl()
Expand Down Expand Up @@ -69,6 +74,16 @@ internal class ShareViewModel(
is ShareEvent.OnUserSharePreferencesChanged -> handleUserSharePreferencesChange(shareEvent.shareFieldType)
is ShareEvent.OnPrivateInformationClicked -> showPrivateInformationDialog()
is ShareEvent.OnDismissPrivateInformationDialog -> hidePrivateInformationDialog()
ShareEvent.OnShareClick -> shareVCard()
}
}

private fun shareVCard() {
viewModelScope.launch {
val vCardContent = uiState.value.vCardQrCodeData.exportToString(withPhoto = true)
val vCardFile = fileUtils.createVCardFile(uiState.value.profile?.displayName.orEmpty(), vCardContent)

_actions.send(ShareAction.ShareVCard(vCardFile))
}
}

Expand All @@ -78,7 +93,7 @@ internal class ShareViewModel(
_uiState.value = this
// Save the updated preferences
viewModelScope.launch {
updateUserSharePreferences(this@with.userSharePreferences)
sharePreferencesFacade.updatePreferences(this@with.userSharePreferences)
}
}
}
Expand All @@ -90,7 +105,7 @@ internal class ShareViewModel(
// Create a new job with debounce
saveContactInfoJob = viewModelScope.launch {
delay(debounceDelay) // Wait for the debounce period
updatePrivateContactInfo(_uiState.value.privateContactInfo)
privateContactInfoFacade.updateContactInfo(_uiState.value.privateContactInfo)
}
}

Expand Down Expand Up @@ -121,6 +136,7 @@ internal class ShareViewModel(
private fun collectAvatarUrl() {
getAvatarUrl()
.onEach { avatarUrl ->
loadDrawable(avatarUrl?.toString())
_uiState.update { currentState ->
currentState.copy(
avatarUrl = avatarUrl?.toString(),
Expand All @@ -130,6 +146,19 @@ internal class ShareViewModel(
.launchIn(viewModelScope)
}

private fun loadDrawable(avatarUrl: String?) {
viewModelScope.launch {
avatarUrl?.let {
val drawableAvatar = drawableUtils.downloadDrawable(avatarUrl)
_uiState.update { currentState ->
currentState.copy(
avatarDrawable = drawableAvatar,
)
}
}
}
}

private fun collectProfile() {
userRepository.getProfile()
.onEach { profile ->
Expand All @@ -143,7 +172,7 @@ internal class ShareViewModel(
}

private fun collectUserSharePreferences() {
getUserSharePreferences()
sharePreferencesFacade.getPreferences()
.onEach { preferences ->
_uiState.update { currentState ->
currentState.copy(
Expand All @@ -155,7 +184,7 @@ internal class ShareViewModel(
}

private fun collectPrivateContactInfo() {
getPrivateContactInfo()
privateContactInfoFacade.getContactInfo()
.onEach { privateContactInfo ->
_uiState.update { currentState ->
currentState.copy(
Expand Down
Loading