Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.infomaniak.core.auth

import androidx.annotation.CallSuper
import androidx.collection.ArrayMap
import com.infomaniak.core.auth.models.user.Card
import com.infomaniak.core.auth.models.user.User
import com.infomaniak.core.auth.room.UserDatabase
import com.infomaniak.core.network.networking.HttpClientConfig
Expand Down Expand Up @@ -58,8 +59,8 @@ abstract class BaseCredentialManager : UserExistenceChecker {
}

@CallSuper
open suspend fun updateUser(user: User) {
userDatabase.userDao().update(user)
open suspend fun updateUserCard(userId: Int, card: Card?) {
userDatabase.userDao().updateUserCard(userId, card)
}
//endregion

Expand Down
4 changes: 4 additions & 0 deletions Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import androidx.room.Upsert
import com.infomaniak.core.auth.models.user.Card
import com.infomaniak.core.auth.models.user.User
import kotlinx.coroutines.flow.Flow

Expand Down Expand Up @@ -72,6 +73,9 @@ interface UserDao {
@Update
suspend fun update(user: User)

@Query("UPDATE user SET card = :card WHERE id = :userId")
suspend fun updateUserCard(userId: Int, card: Card?)

@Upsert
suspend fun upsert(user: User)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/*
* Infomaniak Core - Android
* Copyright (C) 2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.core.ui.compose.contactcard

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.infomaniak.core.auth.UserAccountUtils
import com.infomaniak.core.auth.models.user.Card
import com.infomaniak.core.auth.models.user.CardLink
import com.infomaniak.core.auth.models.user.CardLinkType
import com.infomaniak.core.auth.models.user.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID

class ContactCardViewModel(
application: Application,
savedStateHandle: SavedStateHandle,
) : AndroidViewModel(application) {

private val accountUtils = UserAccountUtils(application.applicationContext)
private val userId: Int = requireNotNull(savedStateHandle.get<Int>(USER_ID_KEY)) { "userId argument is required" }

private val _uiState = MutableStateFlow<ContactCardUiState>(ContactCardUiState.Loading)
val uiState: StateFlow<ContactCardUiState> = _uiState.asStateFlow()

private var currentUser: User? = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having a var and checking everywhere if user is null to do an early return, it would be great to just quit the VCard screen. It makes no sense to be on that screen without a user.


init {
loadUser()
}

fun loadUser() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is public ? It seems you only use if in the init { ... }.

viewModelScope.launch {
val user = accountUtils.getUserById(userId)
currentUser = user
if (_uiState.value !is ContactCardUiState.Editing) {
_uiState.value = user?.toUiState() ?: ContactCardUiState.Error
}
}
}

fun startCreate() {
val user = currentUser ?: return
val editor = ContactCardEditorState.fromUser(user)
_uiState.value = ContactCardUiState.Editing(
user = user,
editor = editor,
existingCard = null,
isValid = editor.validate(),
)
}

fun startEdit(card: Card) {
val user = currentUser ?: return
val editor = ContactCardEditorState.fromCard(card, user.avatar)
_uiState.value = ContactCardUiState.Editing(
user = user,
editor = editor,
existingCard = card,
isValid = editor.validate(),
)
}

fun cancelEditing() {
_uiState.value = currentUser?.toUiState() ?: ContactCardUiState.Error
}

fun updateDraft(editor: ContactCardEditorState) {
val current = _uiState.value as? ContactCardUiState.Editing ?: return
_uiState.value = current.copy(editor = editor, isValid = editor.validate())
}

fun addAdditionalUrl() {
val current = _uiState.value as? ContactCardUiState.Editing ?: return
updateDraft(current.editor.copy(additionalUrls = current.editor.additionalUrls + EditableUrl()))
}

fun removeAdditionalUrl(id: String) {
val current = _uiState.value as? ContactCardUiState.Editing ?: return
updateDraft(current.editor.copy(additionalUrls = current.editor.additionalUrls.filterNot { it.id == id }))
}

fun saveDraft() {
val current = _uiState.value as? ContactCardUiState.Editing ?: return

if (!current.editor.validate()) return

viewModelScope.launch {
val card = current.editor.toCard()
accountUtils.updateUserCard(userId, card)
val updatedUser = current.user.copy(card = card)
currentUser = updatedUser
_uiState.value = ContactCardUiState.Preview(user = updatedUser, card = card)
}
}

fun deleteCard() {
val current = _uiState.value as? ContactCardUiState.Preview ?: return

viewModelScope.launch {
accountUtils.updateUserCard(userId, null)
val updatedUser = current.user.copy(card = null)
currentUser = updatedUser
_uiState.value = ContactCardUiState.Onboarding(updatedUser)
}
}

private fun User.toUiState(): ContactCardUiState {
return card?.let { ContactCardUiState.Preview(user = this, card = it) } ?: ContactCardUiState.Onboarding(this)
}

companion object {
const val USER_ID_KEY = "userId"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this to be public.

}
}

sealed interface ContactCardUiState {
data object Loading : ContactCardUiState
data object Error : ContactCardUiState
data class Onboarding(val user: User) : ContactCardUiState
data class Preview(val user: User, val card: Card) : ContactCardUiState
data class Editing(
val user: User,
val editor: ContactCardEditorState,
val existingCard: Card?,
val isValid: Boolean = false,
) : ContactCardUiState
}

data class ContactCardEditorState(
val firstName: String,
val lastName: String,
val email: String,
val phone: String,
val company: String,
val avatarUrl: String?,
val linkedIn: String,
val x: String,
val instagram: String,
val facebook: String,
val website: String,
val additionalUrls: List<EditableUrl>,
) {
fun validate(): Boolean {
return firstName.trim().isNotEmpty() &&
lastName.trim().isNotEmpty() &&
email.trim().isNotEmpty() &&
phone.trim().isNotEmpty()
}

fun toCard(): Card {
val links = buildList {
website.trim().takeIf(String::isNotEmpty)?.let { add(CardLink(CardLinkType.Website, it)) }
linkedIn.trim().takeIf(String::isNotEmpty)?.let { add(CardLink(CardLinkType.LinkedIn, it)) }
facebook.trim().takeIf(String::isNotEmpty)?.let { add(CardLink(CardLinkType.Facebook, it)) }
instagram.trim().takeIf(String::isNotEmpty)?.let { add(CardLink(CardLinkType.Instagram, it)) }
x.trim().takeIf(String::isNotEmpty)?.let { add(CardLink(CardLinkType.X, it)) }
additionalUrls.mapNotNull { it.value.trim().takeIf(String::isNotEmpty) }.forEach {
add(CardLink(CardLinkType.Other, it))
}
}.takeIf { it.isNotEmpty() }

return Card(
firstName = firstName.trim(),
lastName = lastName.trim(),
email = email.trim(),
phone = phone.trim(),
company = company.trim().takeIf(String::isNotBlank),
avatarUrl = avatarUrl?.takeIf(String::isNotBlank),
links = links,
)
}

companion object {
fun fromUser(user: User): ContactCardEditorState {
return ContactCardEditorState(
firstName = user.firstname,
lastName = user.lastname,
email = user.email,
phone = "",
company = "",
avatarUrl = user.avatar,
linkedIn = "",
x = "",
instagram = "",
facebook = "",
website = "",
additionalUrls = emptyList(),
)
}

fun fromCard(card: Card, fallbackAvatarUrl: String?): ContactCardEditorState {
val websiteLinks = card.links.orEmpty().filter { it.type == CardLinkType.Website }
val otherUrls = buildList {
websiteLinks.drop(1).forEach { add(it.url) }
card.links.orEmpty().filter { it.type == CardLinkType.Other }.forEach { add(it.url) }
}

return ContactCardEditorState(
firstName = card.firstName,
lastName = card.lastName,
email = card.email,
phone = card.phone,
company = card.company.orEmpty(),
avatarUrl = card.avatarUrl ?: fallbackAvatarUrl,
linkedIn = card.links.orEmpty().firstOrNull { it.type == CardLinkType.LinkedIn }?.url.orEmpty(),
x = card.links.orEmpty().firstOrNull { it.type == CardLinkType.X }?.url.orEmpty(),
instagram = card.links.orEmpty().firstOrNull { it.type == CardLinkType.Instagram }?.url.orEmpty(),
facebook = card.links.orEmpty().firstOrNull { it.type == CardLinkType.Facebook }?.url.orEmpty(),
website = websiteLinks.firstOrNull()?.url.orEmpty(),
additionalUrls = otherUrls.map { EditableUrl(value = it) },
)
}
}
}

data class EditableUrl(
val id: String = UUID.randomUUID().toString(),
val value: String = "",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Infomaniak Core - Android
* Copyright (C) 2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.core.ui.compose.contactcard.component

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.infomaniak.core.auth.models.user.Card
import com.infomaniak.core.auth.models.user.CardLinkType
import com.infomaniak.core.auth.models.user.User
import com.infomaniak.core.ui.compose.margin.Margin

@Composable
internal fun ContactVCardBloc(user: User, card: Card) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CardCornerRadius),
color = MaterialTheme.colorScheme.surface,
) {
Column(modifier = Modifier.fillMaxWidth()) {
QrCodeHeader(user = user, card = card)
Spacer(Modifier.height(Margin.Medium))

Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Margin.Medium),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "${card.firstName} ${card.lastName}".trim(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(Margin.Mini))
Text(
text = card.email,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}

Spacer(Modifier.height(Margin.Medium))

ContactInfoRows(
card = card,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Margin.Medium),
)

val links = card.links.orEmpty().filter { it.url.isNotBlank() && it.type != CardLinkType.Website }
if (links.isNotEmpty()) {
Spacer(Modifier.height(Margin.Medium))
LinksRow(
links = links,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Margin.Medium, vertical = Margin.Small),
)
} else {
Spacer(Modifier.height(Margin.Medium))
}
}
}
}

@Preview(name = "ContactVCardBloc")
@Composable
private fun ContactVCardBlocPreview() {
MaterialTheme {
Surface {
ContactVCardBloc(
user = previewUser(),
card = previewCard(),
)
}
}
}
Loading
Loading