feat(ui): import VCF massif anti-doublons résumé
EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:52
7080400795e248416fb023203d493c4ac13c15c8
1 parent(s)
5 fichiers modifiés
+267
-1
A
android/app/src/main/java/fr/ebii/card2vcf/ui/importvcf/VcfImportScreen.kt
+181
-0
@@ -0,0 +1,181 @@
| 1 | +package fr.ebii.card2vcf.ui.importvcf | |
| 2 | + | |
| 3 | +import androidx.activity.compose.rememberLauncherForActivityResult | |
| 4 | +import androidx.activity.result.contract.ActivityResultContracts | |
| 5 | +import androidx.compose.foundation.background | |
| 6 | +import androidx.compose.foundation.layout.Arrangement | |
| 7 | +import androidx.compose.foundation.layout.Box | |
| 8 | +import androidx.compose.foundation.layout.Column | |
| 9 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 10 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 11 | +import androidx.compose.foundation.layout.padding | |
| 12 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 13 | +import androidx.compose.material.icons.Icons | |
| 14 | +import androidx.compose.material.icons.automirrored.outlined.ArrowBack | |
| 15 | +import androidx.compose.material3.Button | |
| 16 | +import androidx.compose.material3.ButtonDefaults | |
| 17 | +import androidx.compose.material3.CircularProgressIndicator | |
| 18 | +import androidx.compose.material3.Icon | |
| 19 | +import androidx.compose.material3.IconButton | |
| 20 | +import androidx.compose.material3.MaterialTheme | |
| 21 | +import androidx.compose.material3.OutlinedButton | |
| 22 | +import androidx.compose.material3.Text | |
| 23 | +import androidx.compose.runtime.Composable | |
| 24 | +import androidx.compose.runtime.collectAsState | |
| 25 | +import androidx.compose.runtime.getValue | |
| 26 | +import androidx.compose.ui.Alignment | |
| 27 | +import androidx.compose.ui.Modifier | |
| 28 | +import androidx.compose.ui.platform.LocalContext | |
| 29 | +import androidx.compose.ui.res.stringResource | |
| 30 | +import androidx.compose.ui.unit.dp | |
| 31 | +import fr.ebii.card2vcf.R | |
| 32 | +import fr.ebii.card2vcf.ui.theme.Encre | |
| 33 | +import fr.ebii.card2vcf.ui.theme.Fond | |
| 34 | +import fr.ebii.card2vcf.ui.theme.Ink | |
| 35 | +import fr.ebii.card2vcf.ui.theme.OnPrimary | |
| 36 | +import fr.ebii.card2vcf.ui.theme.Surface | |
| 37 | +import fr.ebii.card2vcf.ui.theme.TexteFaible | |
| 38 | + | |
| 39 | +private val Square = RoundedCornerShape(0.dp) | |
| 40 | + | |
| 41 | +@Composable | |
| 42 | +fun VcfImportScreen( | |
| 43 | + viewModel: VcfImportViewModel, | |
| 44 | + onBack: () -> Unit, | |
| 45 | + modifier: Modifier = Modifier, | |
| 46 | +) { | |
| 47 | + val state by viewModel.state.collectAsState() | |
| 48 | + val context = LocalContext.current | |
| 49 | + | |
| 50 | + val openDocument = rememberLauncherForActivityResult( | |
| 51 | + ActivityResultContracts.OpenDocument(), | |
| 52 | + ) { uri -> | |
| 53 | + uri ?: return@rememberLauncherForActivityResult | |
| 54 | + viewModel.importFromUri(context.contentResolver, uri) | |
| 55 | + } | |
| 56 | + | |
| 57 | + fun launchPicker() { | |
| 58 | + openDocument.launch(arrayOf("text/x-vcard", "text/vcard", "*/*")) | |
| 59 | + } | |
| 60 | + | |
| 61 | + Column(modifier.fillMaxSize().background(Fond)) { | |
| 62 | + Box( | |
| 63 | + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp), | |
| 64 | + ) { | |
| 65 | + IconButton(onClick = onBack) { | |
| 66 | + Icon( | |
| 67 | + Icons.AutoMirrored.Outlined.ArrowBack, | |
| 68 | + contentDescription = stringResource(R.string.scan_retour), | |
| 69 | + ) | |
| 70 | + } | |
| 71 | + Text( | |
| 72 | + stringResource(R.string.import_titre), | |
| 73 | + style = MaterialTheme.typography.titleMedium, | |
| 74 | + modifier = Modifier.align(Alignment.Center), | |
| 75 | + color = Encre, | |
| 76 | + ) | |
| 77 | + } | |
| 78 | + | |
| 79 | + when (val s = state) { | |
| 80 | + VcfImportUiState.Idle -> Column( | |
| 81 | + Modifier | |
| 82 | + .fillMaxSize() | |
| 83 | + .padding(18.dp), | |
| 84 | + verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically), | |
| 85 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 86 | + ) { | |
| 87 | + Text(stringResource(R.string.import_sous_titre), color = TexteFaible) | |
| 88 | + Button( | |
| 89 | + onClick = { launchPicker() }, | |
| 90 | + modifier = Modifier.fillMaxWidth(), | |
| 91 | + shape = Square, | |
| 92 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 93 | + ) { | |
| 94 | + Text(stringResource(R.string.import_choisir_fichier)) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + VcfImportUiState.Loading -> Column( | |
| 98 | + Modifier.fillMaxSize(), | |
| 99 | + verticalArrangement = Arrangement.Center, | |
| 100 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 101 | + ) { | |
| 102 | + CircularProgressIndicator() | |
| 103 | + Text( | |
| 104 | + stringResource(R.string.import_en_cours), | |
| 105 | + modifier = Modifier.padding(top = 16.dp), | |
| 106 | + color = TexteFaible, | |
| 107 | + ) | |
| 108 | + } | |
| 109 | + is VcfImportUiState.Done -> Column( | |
| 110 | + Modifier | |
| 111 | + .fillMaxSize() | |
| 112 | + .padding(18.dp), | |
| 113 | + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), | |
| 114 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 115 | + ) { | |
| 116 | + Text( | |
| 117 | + stringResource(R.string.import_resume_titre), | |
| 118 | + style = MaterialTheme.typography.titleLarge, | |
| 119 | + color = Encre, | |
| 120 | + ) | |
| 121 | + Text( | |
| 122 | + stringResource(R.string.import_resume_importes, s.result.imported), | |
| 123 | + color = Encre, | |
| 124 | + ) | |
| 125 | + Text( | |
| 126 | + stringResource(R.string.import_resume_doublons, s.result.skippedDuplicates), | |
| 127 | + color = TexteFaible, | |
| 128 | + ) | |
| 129 | + Text( | |
| 130 | + stringResource(R.string.import_resume_erreurs, s.result.errors), | |
| 131 | + color = TexteFaible, | |
| 132 | + ) | |
| 133 | + Button( | |
| 134 | + onClick = onBack, | |
| 135 | + modifier = Modifier.fillMaxWidth(), | |
| 136 | + shape = Square, | |
| 137 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 138 | + ) { | |
| 139 | + Text(stringResource(R.string.import_retour_carnet)) | |
| 140 | + } | |
| 141 | + OutlinedButton( | |
| 142 | + onClick = { | |
| 143 | + viewModel.reset() | |
| 144 | + launchPicker() | |
| 145 | + }, | |
| 146 | + modifier = Modifier.fillMaxWidth(), | |
| 147 | + shape = Square, | |
| 148 | + ) { | |
| 149 | + Text(stringResource(R.string.import_autre_fichier)) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + is VcfImportUiState.Error -> Column( | |
| 153 | + Modifier | |
| 154 | + .fillMaxSize() | |
| 155 | + .padding(18.dp), | |
| 156 | + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), | |
| 157 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 158 | + ) { | |
| 159 | + Text(s.message, color = Encre) | |
| 160 | + Button( | |
| 161 | + onClick = { | |
| 162 | + viewModel.reset() | |
| 163 | + launchPicker() | |
| 164 | + }, | |
| 165 | + modifier = Modifier.fillMaxWidth(), | |
| 166 | + shape = Square, | |
| 167 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 168 | + ) { | |
| 169 | + Text(stringResource(R.string.import_reessayer)) | |
| 170 | + } | |
| 171 | + OutlinedButton( | |
| 172 | + onClick = onBack, | |
| 173 | + modifier = Modifier.fillMaxWidth(), | |
| 174 | + shape = Square, | |
| 175 | + ) { | |
| 176 | + Text(stringResource(R.string.scan_retour)) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + } | |
| 181 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/importvcf/VcfImportViewModel.kt
+55
-0
@@ -0,0 +1,55 @@
| 1 | +package fr.ebii.card2vcf.ui.importvcf | |
| 2 | + | |
| 3 | +import android.content.ContentResolver | |
| 4 | +import android.net.Uri | |
| 5 | +import androidx.lifecycle.ViewModel | |
| 6 | +import androidx.lifecycle.viewModelScope | |
| 7 | +import fr.ebii.card2vcf.contact.VCardParser | |
| 8 | +import fr.ebii.card2vcf.data.ContactRepository | |
| 9 | +import fr.ebii.card2vcf.data.VcfImportResult | |
| 10 | +import kotlinx.coroutines.Dispatchers | |
| 11 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 12 | +import kotlinx.coroutines.flow.StateFlow | |
| 13 | +import kotlinx.coroutines.flow.asStateFlow | |
| 14 | +import kotlinx.coroutines.launch | |
| 15 | +import kotlinx.coroutines.withContext | |
| 16 | +import java.io.BufferedReader | |
| 17 | +import java.io.InputStreamReader | |
| 18 | +import java.nio.charset.StandardCharsets | |
| 19 | + | |
| 20 | +sealed interface VcfImportUiState { | |
| 21 | + data object Idle : VcfImportUiState | |
| 22 | + data object Loading : VcfImportUiState | |
| 23 | + data class Done(val result: VcfImportResult) : VcfImportUiState | |
| 24 | + data class Error(val message: String) : VcfImportUiState | |
| 25 | +} | |
| 26 | + | |
| 27 | +class VcfImportViewModel( | |
| 28 | + private val repository: ContactRepository, | |
| 29 | +) : ViewModel() { | |
| 30 | + | |
| 31 | + private val _state = MutableStateFlow<VcfImportUiState>(VcfImportUiState.Idle) | |
| 32 | + val state: StateFlow<VcfImportUiState> = _state.asStateFlow() | |
| 33 | + | |
| 34 | + fun importFromUri(resolver: ContentResolver, uri: Uri) { | |
| 35 | + viewModelScope.launch { | |
| 36 | + _state.value = VcfImportUiState.Loading | |
| 37 | + try { | |
| 38 | + val text = withContext(Dispatchers.IO) { | |
| 39 | + resolver.openInputStream(uri)?.use { stream -> | |
| 40 | + BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8)).readText() | |
| 41 | + } ?: throw IllegalStateException("Impossible de lire le fichier") | |
| 42 | + } | |
| 43 | + val cards = withContext(Dispatchers.Default) { VCardParser.parse(text) } | |
| 44 | + val result = withContext(Dispatchers.IO) { repository.importCards(cards) } | |
| 45 | + _state.value = VcfImportUiState.Done(result) | |
| 46 | + } catch (t: Throwable) { | |
| 47 | + _state.value = VcfImportUiState.Error(t.message ?: "import_erreur") | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + fun reset() { | |
| 53 | + _state.value = VcfImportUiState.Idle | |
| 54 | + } | |
| 55 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+7
-1
@@ -32,6 +32,8 @@ import fr.ebii.card2vcf.ui.contact.ContactPagerViewModel
| 32 | 32 | import fr.ebii.card2vcf.ui.contact.DuplicateReviewScreen |
| 33 | 33 | import fr.ebii.card2vcf.ui.contact.DuplicateReviewViewModel |
| 34 | 34 | import fr.ebii.card2vcf.ui.contact.ManualContactScreen |
| 35 | +import fr.ebii.card2vcf.ui.importvcf.VcfImportScreen | |
| 36 | +import fr.ebii.card2vcf.ui.importvcf.VcfImportViewModel | |
| 35 | 37 | import kotlinx.coroutines.launch |
| 36 | 38 | |
| 37 | 39 | object Routes { |
@@ -203,7 +205,11 @@ fun Card2vcfNavHost(
| 203 | 205 | PlaceholderRoute("Brouillon") |
| 204 | 206 | } |
| 205 | 207 | composable(Routes.ImportVcf) { |
| 206 | - PlaceholderRoute("Import VCF") | |
| 208 | + val importVm = remember(repository) { VcfImportViewModel(repository) } | |
| 209 | + VcfImportScreen( | |
| 210 | + viewModel = importVm, | |
| 211 | + onBack = { navController.popBackStack() }, | |
| 212 | + ) | |
| 207 | 213 | } |
| 208 | 214 | } |
| 209 | 215 | } |
M
android/app/src/main/res/values-en/strings.xml
+12
-0
@@ -69,4 +69,16 @@
| 69 | 69 | <string name="dup_image_aucune">None</string> |
| 70 | 70 | <string name="dup_annuler">Cancel</string> |
| 71 | 71 | <string name="dup_confirmer_fusion">Confirm merge</string> |
| 72 | + | |
| 73 | + <string name="import_titre">Import VCF</string> | |
| 74 | + <string name="import_sous_titre">Pick a vCard file (.vcf). Duplicates (email or phone) are skipped.</string> | |
| 75 | + <string name="import_choisir_fichier">Choose a file</string> | |
| 76 | + <string name="import_en_cours">Importing…</string> | |
| 77 | + <string name="import_resume_titre">Summary</string> | |
| 78 | + <string name="import_resume_importes">Imported: %d</string> | |
| 79 | + <string name="import_resume_doublons">Duplicates skipped: %d</string> | |
| 80 | + <string name="import_resume_erreurs">Errors: %d</string> | |
| 81 | + <string name="import_retour_carnet">Back to address book</string> | |
| 82 | + <string name="import_autre_fichier">Import another file</string> | |
| 83 | + <string name="import_reessayer">Try again</string> | |
| 72 | 84 | </resources> |
M
android/app/src/main/res/values/strings.xml
+12
-0
@@ -69,4 +69,16 @@
| 69 | 69 | <string name="dup_image_aucune">Aucune</string> |
| 70 | 70 | <string name="dup_annuler">Annuler</string> |
| 71 | 71 | <string name="dup_confirmer_fusion">Confirmer la fusion</string> |
| 72 | + | |
| 73 | + <string name="import_titre">Import VCF</string> | |
| 74 | + <string name="import_sous_titre">Choisissez un fichier vCard (.vcf). Les doublons (e-mail ou téléphone) sont ignorés.</string> | |
| 75 | + <string name="import_choisir_fichier">Choisir un fichier</string> | |
| 76 | + <string name="import_en_cours">Import en cours…</string> | |
| 77 | + <string name="import_resume_titre">Résumé</string> | |
| 78 | + <string name="import_resume_importes">Importés : %d</string> | |
| 79 | + <string name="import_resume_doublons">Doublons ignorés : %d</string> | |
| 80 | + <string name="import_resume_erreurs">Erreurs : %d</string> | |
| 81 | + <string name="import_retour_carnet">Retour au carnet</string> | |
| 82 | + <string name="import_autre_fichier">Importer un autre fichier</string> | |
| 83 | + <string name="import_reessayer">Réessayer</string> | |
| 72 | 84 | </resources> |
GitRust