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>