feat(ui): puce doublons revue fusion et exclusions

EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:49

337caa39a4944095cd0b1408959f3208b455e682

1 parent(s)

5 fichiers modifiés +648 -2
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewScreen.kt
+476 -0
@@ -0,0 +1,476 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.graphics.BitmapFactory
4 +import androidx.compose.foundation.Image
5 +import androidx.compose.foundation.background
6 +import androidx.compose.foundation.border
7 +import androidx.compose.foundation.clickable
8 +import androidx.compose.foundation.layout.Arrangement
9 +import androidx.compose.foundation.layout.Box
10 +import androidx.compose.foundation.layout.Column
11 +import androidx.compose.foundation.layout.Row
12 +import androidx.compose.foundation.layout.Spacer
13 +import androidx.compose.foundation.layout.fillMaxSize
14 +import androidx.compose.foundation.layout.fillMaxWidth
15 +import androidx.compose.foundation.layout.height
16 +import androidx.compose.foundation.layout.padding
17 +import androidx.compose.foundation.layout.size
18 +import androidx.compose.foundation.rememberScrollState
19 +import androidx.compose.foundation.selection.selectable
20 +import androidx.compose.foundation.shape.RoundedCornerShape
21 +import androidx.compose.foundation.verticalScroll
22 +import androidx.compose.material.icons.Icons
23 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
24 +import androidx.compose.material3.Button
25 +import androidx.compose.material3.ButtonDefaults
26 +import androidx.compose.material3.Checkbox
27 +import androidx.compose.material3.CheckboxDefaults
28 +import androidx.compose.material3.HorizontalDivider
29 +import androidx.compose.material3.Icon
30 +import androidx.compose.material3.IconButton
31 +import androidx.compose.material3.MaterialTheme
32 +import androidx.compose.material3.OutlinedButton
33 +import androidx.compose.material3.RadioButton
34 +import androidx.compose.material3.RadioButtonDefaults
35 +import androidx.compose.material3.Text
36 +import androidx.compose.runtime.Composable
37 +import androidx.compose.runtime.LaunchedEffect
38 +import androidx.compose.runtime.collectAsState
39 +import androidx.compose.runtime.getValue
40 +import androidx.compose.runtime.mutableStateOf
41 +import androidx.compose.runtime.remember
42 +import androidx.compose.runtime.setValue
43 +import androidx.compose.ui.Alignment
44 +import androidx.compose.ui.Modifier
45 +import androidx.compose.ui.graphics.asImageBitmap
46 +import androidx.compose.ui.layout.ContentScale
47 +import androidx.compose.ui.res.stringResource
48 +import androidx.compose.ui.semantics.Role
49 +import androidx.compose.ui.unit.dp
50 +import fr.ebii.card2vcf.R
51 +import fr.ebii.card2vcf.crm.MergeFieldChoices
52 +import fr.ebii.card2vcf.crm.NotesMode
53 +import fr.ebii.card2vcf.data.CrmContactEntity
54 +import fr.ebii.card2vcf.ui.theme.Bordure
55 +import fr.ebii.card2vcf.ui.theme.Encre
56 +import fr.ebii.card2vcf.ui.theme.Fond
57 +import fr.ebii.card2vcf.ui.theme.Ink
58 +import fr.ebii.card2vcf.ui.theme.OnPrimary
59 +import fr.ebii.card2vcf.ui.theme.Surface
60 +import fr.ebii.card2vcf.ui.theme.TexteFaible
61 +
62 +private val Square = RoundedCornerShape(0.dp)
63 +
64 +@Composable
65 +fun DuplicateReviewScreen(
66 + viewModel: DuplicateReviewViewModel,
67 + onBack: () -> Unit,
68 + onMerged: (keepId: Long) -> Unit,
69 + modifier: Modifier = Modifier,
70 +) {
71 + val cluster by viewModel.cluster.collectAsState()
72 + val selected by viewModel.selectedIds.collectAsState()
73 + val merging by viewModel.merging.collectAsState()
74 + val mergeDoneKeepId by viewModel.mergeDoneKeepId.collectAsState()
75 +
76 + LaunchedEffect(mergeDoneKeepId) {
77 + mergeDoneKeepId?.let(onMerged)
78 + }
79 +
80 + val primary = cluster.firstOrNull { it.id == viewModel.contactId }
81 + val others = cluster.filter { it.id != viewModel.contactId }
82 +
83 + Column(
84 + modifier
85 + .fillMaxSize()
86 + .background(Fond),
87 + ) {
88 + Row(
89 + Modifier
90 + .fillMaxWidth()
91 + .background(Surface)
92 + .padding(horizontal = 4.dp, vertical = 4.dp),
93 + verticalAlignment = Alignment.CenterVertically,
94 + ) {
95 + IconButton(
96 + onClick = {
97 + if (merging) viewModel.cancelMerge() else onBack()
98 + },
99 + ) {
100 + Icon(
101 + Icons.AutoMirrored.Outlined.ArrowBack,
102 + contentDescription = stringResource(R.string.scan_retour),
103 + )
104 + }
105 + Text(
106 + stringResource(
107 + if (merging) R.string.dup_fusion_titre else R.string.dup_revue_titre,
108 + ),
109 + style = MaterialTheme.typography.titleMedium,
110 + color = Encre,
111 + modifier = Modifier.padding(start = 4.dp),
112 + )
113 + }
114 +
115 + if (merging) {
116 + val sources = viewModel.mergeSources(cluster)
117 + if (sources.size < 2) {
118 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
119 + Text(stringResource(R.string.dup_aucun), color = TexteFaible)
120 + }
121 + } else {
122 + MergePanel(
123 + sources = sources,
124 + initial = viewModel.defaultChoices(sources),
125 + onConfirm = { choices -> viewModel.confirmMerge(sources, choices) },
126 + onCancel = viewModel::cancelMerge,
127 + )
128 + }
129 + return@Column
130 + }
131 +
132 + if (others.isEmpty()) {
133 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
134 + Text(stringResource(R.string.dup_aucun), color = TexteFaible)
135 + }
136 + return@Column
137 + }
138 +
139 + Column(
140 + Modifier
141 + .weight(1f)
142 + .fillMaxWidth()
143 + .verticalScroll(rememberScrollState())
144 + .padding(horizontal = 18.dp, vertical = 12.dp),
145 + verticalArrangement = Arrangement.spacedBy(12.dp),
146 + ) {
147 + if (primary != null) {
148 + Text(
149 + stringResource(R.string.dup_contact_courant),
150 + style = MaterialTheme.typography.labelMedium,
151 + color = TexteFaible,
152 + )
153 + ContactSummaryRow(contact = primary, selected = false, onToggle = null)
154 + HorizontalDivider(color = Bordure, thickness = 1.dp)
155 + }
156 +
157 + Text(
158 + stringResource(R.string.dup_autres),
159 + style = MaterialTheme.typography.labelMedium,
160 + color = TexteFaible,
161 + )
162 + others.forEach { c ->
163 + ContactSummaryRow(
164 + contact = c,
165 + selected = c.id in selected,
166 + onToggle = { viewModel.toggleSelect(c.id) },
167 + )
168 + }
169 + }
170 +
171 + Column(
172 + Modifier
173 + .fillMaxWidth()
174 + .padding(horizontal = 18.dp, vertical = 12.dp),
175 + verticalArrangement = Arrangement.spacedBy(8.dp),
176 + ) {
177 + val hasSelection = selected.isNotEmpty()
178 + OutlinedButton(
179 + onClick = viewModel::deleteSelected,
180 + enabled = hasSelection,
181 + modifier = Modifier.fillMaxWidth(),
182 + shape = Square,
183 + ) {
184 + Text(stringResource(R.string.dup_supprimer))
185 + }
186 + OutlinedButton(
187 + onClick = viewModel::markSelectedDifferent,
188 + enabled = hasSelection,
189 + modifier = Modifier.fillMaxWidth(),
190 + shape = Square,
191 + ) {
192 + Text(stringResource(R.string.dup_marquer_differents))
193 + }
194 + Button(
195 + onClick = viewModel::startMerge,
196 + modifier = Modifier.fillMaxWidth(),
197 + shape = Square,
198 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
199 + ) {
200 + Text(stringResource(R.string.dup_fusionner))
201 + }
202 + }
203 + }
204 +}
205 +
206 +@Composable
207 +private fun ContactSummaryRow(
208 + contact: CrmContactEntity,
209 + selected: Boolean,
210 + onToggle: (() -> Unit)?,
211 +) {
212 + Row(
213 + Modifier
214 + .fillMaxWidth()
215 + .border(1.dp, Bordure, Square)
216 + .then(
217 + if (onToggle != null) {
218 + Modifier.clickable(onClick = onToggle)
219 + } else {
220 + Modifier
221 + },
222 + )
223 + .padding(12.dp),
224 + verticalAlignment = Alignment.CenterVertically,
225 + horizontalArrangement = Arrangement.spacedBy(10.dp),
226 + ) {
227 + if (onToggle != null) {
228 + Checkbox(
229 + checked = selected,
230 + onCheckedChange = { onToggle() },
231 + colors = CheckboxDefaults.colors(checkedColor = Ink, checkmarkColor = OnPrimary),
232 + )
233 + }
234 + Column(Modifier.weight(1f)) {
235 + Text(displayName(contact), style = MaterialTheme.typography.bodyLarge, color = Encre)
236 + val detail = listOfNotNull(
237 + contact.emails.firstOrNull()?.takeIf { it.isNotBlank() },
238 + contact.phones.firstOrNull()?.takeIf { it.isNotBlank() },
239 + contact.company?.takeIf { it.isNotBlank() },
240 + ).joinToString(" · ")
241 + if (detail.isNotEmpty()) {
242 + Text(detail, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
243 + }
244 + }
245 + }
246 +}
247 +
248 +@Composable
249 +private fun MergePanel(
250 + sources: List<CrmContactEntity>,
251 + initial: MergeFieldChoices,
252 + onConfirm: (MergeFieldChoices) -> Unit,
253 + onCancel: () -> Unit,
254 +) {
255 + var choices by remember(sources.map { it.id }) { mutableStateOf(initial) }
256 +
257 + Column(
258 + Modifier
259 + .fillMaxSize()
260 + .verticalScroll(rememberScrollState())
261 + .padding(horizontal = 18.dp, vertical = 12.dp),
262 + verticalArrangement = Arrangement.spacedBy(14.dp),
263 + ) {
264 + Text(stringResource(R.string.dup_garder), color = TexteFaible)
265 + sources.forEach { c ->
266 + SourceRadioRow(
267 + label = displayName(c),
268 + selected = choices.keepId == c.id,
269 + onSelect = { choices = choices.copy(keepId = c.id) },
270 + )
271 + }
272 +
273 + HorizontalDivider(color = Bordure, thickness = 1.dp)
274 +
275 + FieldPicker(
276 + label = stringResource(R.string.scan_champ_nom),
277 + sources = sources,
278 + selectedId = choices.fullNameFromId,
279 + valueOf = { it.fullName },
280 + onSelect = { choices = choices.copy(fullNameFromId = it) },
281 + )
282 + FieldPicker(
283 + label = stringResource(R.string.scan_champ_prenom),
284 + sources = sources,
285 + selectedId = choices.firstNameFromId,
286 + valueOf = { it.firstName },
287 + onSelect = { choices = choices.copy(firstNameFromId = it) },
288 + )
289 + FieldPicker(
290 + label = stringResource(R.string.scan_champ_nom_famille),
291 + sources = sources,
292 + selectedId = choices.lastNameFromId,
293 + valueOf = { it.lastName },
294 + onSelect = { choices = choices.copy(lastNameFromId = it) },
295 + )
296 + FieldPicker(
297 + label = stringResource(R.string.scan_champ_societe),
298 + sources = sources,
299 + selectedId = choices.companyFromId,
300 + valueOf = { it.company },
301 + onSelect = { choices = choices.copy(companyFromId = it) },
302 + )
303 + FieldPicker(
304 + label = stringResource(R.string.scan_champ_poste),
305 + sources = sources,
306 + selectedId = choices.jobTitleFromId,
307 + valueOf = { it.jobTitle },
308 + onSelect = { choices = choices.copy(jobTitleFromId = it) },
309 + )
310 + FieldPicker(
311 + label = stringResource(R.string.scan_champ_site),
312 + sources = sources,
313 + selectedId = choices.websiteFromId,
314 + valueOf = { it.website },
315 + onSelect = { choices = choices.copy(websiteFromId = it) },
316 + )
317 + FieldPicker(
318 + label = stringResource(R.string.scan_champ_adresse),
319 + sources = sources,
320 + selectedId = choices.addressFromId,
321 + valueOf = { it.address },
322 + onSelect = { choices = choices.copy(addressFromId = it) },
323 + )
324 +
325 + Text(stringResource(R.string.dup_notes_mode), color = TexteFaible)
326 + SourceRadioRow(
327 + label = stringResource(R.string.dup_notes_garder),
328 + selected = choices.notesMode == NotesMode.KEEP_PRIMARY,
329 + onSelect = { choices = choices.copy(notesMode = NotesMode.KEEP_PRIMARY) },
330 + )
331 + SourceRadioRow(
332 + label = stringResource(R.string.dup_notes_concat),
333 + selected = choices.notesMode == NotesMode.CONCAT,
334 + onSelect = { choices = choices.copy(notesMode = NotesMode.CONCAT) },
335 + )
336 +
337 + ImagePicker(
338 + label = stringResource(R.string.fiche_photo_carte),
339 + sources = sources,
340 + selectedId = choices.cardImageFromId,
341 + pathOf = { it.cardImagePath },
342 + onSelect = { choices = choices.copy(cardImageFromId = it) },
343 + )
344 + ImagePicker(
345 + label = stringResource(R.string.fiche_photo_profil),
346 + sources = sources,
347 + selectedId = choices.profileImageFromId,
348 + pathOf = { it.profileImagePath },
349 + onSelect = { choices = choices.copy(profileImageFromId = it) },
350 + )
351 +
352 + Spacer(Modifier.height(8.dp))
353 + OutlinedButton(
354 + onClick = onCancel,
355 + modifier = Modifier.fillMaxWidth(),
356 + shape = Square,
357 + ) {
358 + Text(stringResource(R.string.dup_annuler))
359 + }
360 + Button(
361 + onClick = { onConfirm(choices) },
362 + modifier = Modifier.fillMaxWidth(),
363 + shape = Square,
364 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
365 + ) {
366 + Text(stringResource(R.string.dup_confirmer_fusion))
367 + }
368 + }
369 +}
370 +
371 +@Composable
372 +private fun FieldPicker(
373 + label: String,
374 + sources: List<CrmContactEntity>,
375 + selectedId: Long,
376 + valueOf: (CrmContactEntity) -> String?,
377 + onSelect: (Long) -> Unit,
378 +) {
379 + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
380 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
381 + sources.forEach { c ->
382 + val value = valueOf(c)?.takeIf { it.isNotBlank() } ?: "—"
383 + SourceRadioRow(
384 + label = "${displayName(c)}: $value",
385 + selected = selectedId == c.id,
386 + onSelect = { onSelect(c.id) },
387 + )
388 + }
389 + }
390 +}
391 +
392 +@Composable
393 +private fun ImagePicker(
394 + label: String,
395 + sources: List<CrmContactEntity>,
396 + selectedId: Long?,
397 + pathOf: (CrmContactEntity) -> String?,
398 + onSelect: (Long?) -> Unit,
399 +) {
400 + val withImage = sources.filter { !pathOf(it).isNullOrBlank() }
401 + if (withImage.isEmpty()) return
402 +
403 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
404 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
405 + SourceRadioRow(
406 + label = stringResource(R.string.dup_image_aucune),
407 + selected = selectedId == null,
408 + onSelect = { onSelect(null) },
409 + )
410 + withImage.forEach { c ->
411 + Row(
412 + Modifier
413 + .fillMaxWidth()
414 + .selectable(
415 + selected = selectedId == c.id,
416 + onClick = { onSelect(c.id) },
417 + role = Role.RadioButton,
418 + )
419 + .padding(vertical = 4.dp),
420 + verticalAlignment = Alignment.CenterVertically,
421 + horizontalArrangement = Arrangement.spacedBy(10.dp),
422 + ) {
423 + RadioButton(
424 + selected = selectedId == c.id,
425 + onClick = { onSelect(c.id) },
426 + colors = RadioButtonDefaults.colors(selectedColor = Ink),
427 + )
428 + val path = pathOf(c)
429 + val bitmap = remember(path) {
430 + path?.let { BitmapFactory.decodeFile(it)?.asImageBitmap() }
431 + }
432 + if (bitmap != null) {
433 + Image(
434 + bitmap = bitmap,
435 + contentDescription = label,
436 + modifier = Modifier
437 + .size(48.dp)
438 + .border(1.dp, Bordure, Square),
439 + contentScale = ContentScale.Crop,
440 + )
441 + }
442 + Text(displayName(c), color = Encre)
443 + }
444 + }
445 + }
446 +}
447 +
448 +@Composable
449 +private fun SourceRadioRow(
450 + label: String,
451 + selected: Boolean,
452 + onSelect: () -> Unit,
453 +) {
454 + Row(
455 + Modifier
456 + .fillMaxWidth()
457 + .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton)
458 + .padding(vertical = 2.dp),
459 + verticalAlignment = Alignment.CenterVertically,
460 + ) {
461 + RadioButton(
462 + selected = selected,
463 + onClick = onSelect,
464 + colors = RadioButtonDefaults.colors(selectedColor = Ink),
465 + )
466 + Text(label, color = Encre, modifier = Modifier.padding(start = 4.dp))
467 + }
468 +}
469 +
470 +private fun displayName(c: CrmContactEntity): String {
471 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
472 + val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim()
473 + if (composed.isNotEmpty()) return composed
474 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
475 + return "#${c.id}"
476 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewViewModel.kt
+125 -0
@@ -0,0 +1,125 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.DuplicateDetector
6 +import fr.ebii.card2vcf.crm.MergeFieldChoices
7 +import fr.ebii.card2vcf.crm.NotesMode
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.CrmContactEntity
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.flow.MutableStateFlow
12 +import kotlinx.coroutines.flow.SharingStarted
13 +import kotlinx.coroutines.flow.StateFlow
14 +import kotlinx.coroutines.flow.asStateFlow
15 +import kotlinx.coroutines.flow.combine
16 +import kotlinx.coroutines.flow.mapLatest
17 +import kotlinx.coroutines.flow.stateIn
18 +import kotlinx.coroutines.flow.update
19 +import kotlinx.coroutines.launch
20 +
21 +@OptIn(ExperimentalCoroutinesApi::class)
22 +class DuplicateReviewViewModel(
23 + private val repository: ContactRepository,
24 + val contactId: Long,
25 +) : ViewModel() {
26 +
27 + private val refresh = MutableStateFlow(0)
28 +
29 + val cluster: StateFlow<List<CrmContactEntity>> = combine(
30 + repository.observeAll(),
31 + refresh,
32 + ) { list, _ -> list }
33 + .mapLatest { list ->
34 + val exclusions = repository.getExclusions()
35 + val clusters = DuplicateDetector.clusters(list, exclusions)
36 + val memberIds = clusters[contactId].orEmpty()
37 + list.filter { it.id in memberIds }.sortedBy { it.id }
38 + }
39 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
40 +
41 + private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
42 + val selectedIds: StateFlow<Set<Long>> = _selectedIds.asStateFlow()
43 +
44 + private val _merging = MutableStateFlow(false)
45 + val merging: StateFlow<Boolean> = _merging.asStateFlow()
46 +
47 + private val _mergeDoneKeepId = MutableStateFlow<Long?>(null)
48 + val mergeDoneKeepId: StateFlow<Long?> = _mergeDoneKeepId.asStateFlow()
49 +
50 + fun toggleSelect(id: Long) {
51 + if (id == contactId) return
52 + _selectedIds.update { cur ->
53 + if (id in cur) cur - id else cur + id
54 + }
55 + }
56 +
57 + fun clearSelection() {
58 + _selectedIds.value = emptySet()
59 + }
60 +
61 + fun deleteSelected() {
62 + val ids = _selectedIds.value
63 + if (ids.isEmpty()) return
64 + viewModelScope.launch {
65 + ids.forEach { repository.delete(it) }
66 + _selectedIds.value = emptySet()
67 + refresh.update { it + 1 }
68 + }
69 + }
70 +
71 + fun markSelectedDifferent() {
72 + val ids = _selectedIds.value
73 + if (ids.isEmpty()) return
74 + viewModelScope.launch {
75 + ids.forEach { repository.markDifferent(contactId, it) }
76 + _selectedIds.value = emptySet()
77 + refresh.update { it + 1 }
78 + }
79 + }
80 +
81 + fun startMerge() {
82 + _merging.value = true
83 + }
84 +
85 + fun cancelMerge() {
86 + _merging.value = false
87 + }
88 +
89 + /** Sources = contact courant + sélection (ou tous les autres si sélection vide). */
90 + fun mergeSources(cluster: List<CrmContactEntity>): List<CrmContactEntity> {
91 + val others = cluster.filter { it.id != contactId }
92 + val selected = _selectedIds.value
93 + val peers = if (selected.isEmpty()) others else others.filter { it.id in selected }
94 + val primary = cluster.firstOrNull { it.id == contactId }
95 + return listOfNotNull(primary) + peers
96 + }
97 +
98 + fun defaultChoices(sources: List<CrmContactEntity>): MergeFieldChoices {
99 + val keep = contactId.takeIf { id -> sources.any { it.id == id } }
100 + ?: sources.first().id
101 + return MergeFieldChoices(
102 + fullNameFromId = keep,
103 + firstNameFromId = keep,
104 + lastNameFromId = keep,
105 + companyFromId = keep,
106 + jobTitleFromId = keep,
107 + websiteFromId = keep,
108 + addressFromId = keep,
109 + notesMode = NotesMode.KEEP_PRIMARY,
110 + cardImageFromId = sources.firstOrNull { !it.cardImagePath.isNullOrBlank() }?.id,
111 + profileImageFromId = sources.firstOrNull { !it.profileImagePath.isNullOrBlank() }?.id,
112 + keepId = keep,
113 + )
114 + }
115 +
116 + fun confirmMerge(sources: List<CrmContactEntity>, choices: MergeFieldChoices) {
117 + if (sources.size < 2) return
118 + viewModelScope.launch {
119 + repository.mergeContacts(sources, choices)
120 + _merging.value = false
121 + _selectedIds.value = emptySet()
122 + _mergeDoneKeepId.value = choices.keepId
123 + }
124 + }
125 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+15 -2
@@ -29,6 +29,8 @@ import fr.ebii.card2vcf.ui.contact.ContactEditScreen
29 29 import fr.ebii.card2vcf.ui.contact.ContactEditViewModel
30 30 import fr.ebii.card2vcf.ui.contact.ContactPagerScreen
31 31 import fr.ebii.card2vcf.ui.contact.ContactPagerViewModel
32 +import fr.ebii.card2vcf.ui.contact.DuplicateReviewScreen
33 +import fr.ebii.card2vcf.ui.contact.DuplicateReviewViewModel
32 34 import fr.ebii.card2vcf.ui.contact.ManualContactScreen
33 35 import kotlinx.coroutines.launch
34 36
@@ -141,8 +143,19 @@ fun Card2vcfNavHost(
141 143 navArgument("contactId") { type = NavType.LongType },
142 144 ),
143 145 ) { entry ->
144 - val id = entry.arguments?.getLong("contactId")
145 - PlaceholderRoute("Doublons #$id")
146 + val id = entry.arguments?.getLong("contactId") ?: return@composable
147 + val dupVm = remember(repository, id) {
148 + DuplicateReviewViewModel(repository, id)
149 + }
150 + DuplicateReviewScreen(
151 + viewModel = dupVm,
152 + onBack = { navController.popBackStack() },
153 + onMerged = { keepId ->
154 + navController.navigate(Routes.fiche(keepId)) {
155 + popUpTo(Routes.Carnet) { inclusive = false }
156 + }
157 + },
158 + )
146 159 }
147 160 composable(
148 161 route = Routes.Scan,
M android/app/src/main/res/values-en/strings.xml
+16 -0
@@ -52,4 +52,20 @@
52 52 <string name="edit_choisir_photo">Choose photo</string>
53 53 <string name="manual_titre">New contact</string>
54 54 <string name="manual_sous_titre">Fill in the fields, then save to the address book.</string>
55 +
56 + <string name="dup_revue_titre">Duplicates</string>
57 + <string name="dup_fusion_titre">Merge</string>
58 + <string name="dup_contact_courant">Current contact</string>
59 + <string name="dup_autres">Similar contacts</string>
60 + <string name="dup_aucun">No duplicates</string>
61 + <string name="dup_supprimer">Delete selection</string>
62 + <string name="dup_marquer_differents">Mark as different</string>
63 + <string name="dup_fusionner">Merge</string>
64 + <string name="dup_garder">Keep this record</string>
65 + <string name="dup_notes_mode">Notes</string>
66 + <string name="dup_notes_garder">Keep notes from the retained record</string>
67 + <string name="dup_notes_concat">Concatenate all notes</string>
68 + <string name="dup_image_aucune">None</string>
69 + <string name="dup_annuler">Cancel</string>
70 + <string name="dup_confirmer_fusion">Confirm merge</string>
55 71 </resources>
M android/app/src/main/res/values/strings.xml
+16 -0
@@ -52,4 +52,20 @@
52 52 <string name="edit_choisir_photo">Choisir une photo</string>
53 53 <string name="manual_titre">Nouveau contact</string>
54 54 <string name="manual_sous_titre">Saisissez les champs, puis enregistrez dans le carnet.</string>
55 +
56 + <string name="dup_revue_titre">Doublons</string>
57 + <string name="dup_fusion_titre">Fusionner</string>
58 + <string name="dup_contact_courant">Contact courant</string>
59 + <string name="dup_autres">Contacts similaires</string>
60 + <string name="dup_aucun">Aucun doublon</string>
61 + <string name="dup_supprimer">Supprimer la sélection</string>
62 + <string name="dup_marquer_differents">Contacts différents</string>
63 + <string name="dup_fusionner">Fusionner</string>
64 + <string name="dup_garder">Conserver la fiche</string>
65 + <string name="dup_notes_mode">Notes</string>
66 + <string name="dup_notes_garder">Garder celles de la fiche conservée</string>
67 + <string name="dup_notes_concat">Concaténer toutes les notes</string>
68 + <string name="dup_image_aucune">Aucune</string>
69 + <string name="dup_annuler">Annuler</string>
70 + <string name="dup_confirmer_fusion">Confirmer la fusion</string>
55 71 </resources>