feat(ui): fiche contact pager swipe double/triple tap

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

614e73926e667773e741a4e747c7dd68f25df9e3

1 parent(s)

6 fichiers modifiés +396 -2
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+2 -0
@@ -21,6 +21,8 @@ class ContactRepository(
21 21
22 22 suspend fun getById(id: Long) = dao.getById(id)
23 23
24 + suspend fun getExclusions(): List<DuplicateExclusionEntity> = dao.getExclusions()
25 +
24 26 suspend fun search(query: String): List<CrmContactEntity> {
25 27 val q = query.trim()
26 28 if (q.isEmpty()) return dao.getAll()
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+323 -0
@@ -0,0 +1,323 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.content.ActivityNotFoundException
4 +import android.content.Intent
5 +import android.graphics.BitmapFactory
6 +import android.net.Uri
7 +import androidx.compose.foundation.Image
8 +import androidx.compose.foundation.background
9 +import androidx.compose.foundation.border
10 +import androidx.compose.foundation.gestures.detectTapGestures
11 +import androidx.compose.foundation.layout.Arrangement
12 +import androidx.compose.foundation.layout.Box
13 +import androidx.compose.foundation.layout.Column
14 +import androidx.compose.foundation.layout.Row
15 +import androidx.compose.foundation.layout.Spacer
16 +import androidx.compose.foundation.layout.fillMaxSize
17 +import androidx.compose.foundation.layout.fillMaxWidth
18 +import androidx.compose.foundation.layout.height
19 +import androidx.compose.foundation.layout.padding
20 +import androidx.compose.foundation.layout.size
21 +import androidx.compose.foundation.pager.HorizontalPager
22 +import androidx.compose.foundation.pager.rememberPagerState
23 +import androidx.compose.foundation.rememberScrollState
24 +import androidx.compose.foundation.shape.RoundedCornerShape
25 +import androidx.compose.foundation.verticalScroll
26 +import androidx.compose.material.icons.Icons
27 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
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.Text
33 +import androidx.compose.material3.TextButton
34 +import androidx.compose.runtime.Composable
35 +import androidx.compose.runtime.collectAsState
36 +import androidx.compose.runtime.getValue
37 +import androidx.compose.runtime.remember
38 +import androidx.compose.runtime.rememberCoroutineScope
39 +import androidx.compose.ui.Alignment
40 +import androidx.compose.ui.Modifier
41 +import androidx.compose.ui.graphics.ImageBitmap
42 +import androidx.compose.ui.graphics.asImageBitmap
43 +import androidx.compose.ui.input.pointer.pointerInput
44 +import androidx.compose.ui.layout.ContentScale
45 +import androidx.compose.ui.platform.LocalContext
46 +import androidx.compose.ui.res.stringResource
47 +import androidx.compose.ui.unit.dp
48 +import fr.ebii.card2vcf.R
49 +import fr.ebii.card2vcf.data.CrmContactEntity
50 +import fr.ebii.card2vcf.ui.theme.Bordure
51 +import fr.ebii.card2vcf.ui.theme.Encre
52 +import fr.ebii.card2vcf.ui.theme.Fond
53 +import fr.ebii.card2vcf.ui.theme.Surface
54 +import fr.ebii.card2vcf.ui.theme.TexteFaible
55 +import kotlinx.coroutines.Job
56 +import kotlinx.coroutines.delay
57 +import kotlinx.coroutines.launch
58 +
59 +private val Square = RoundedCornerShape(0.dp)
60 +private const val TapTimeoutMs = 400L
61 +
62 +@Composable
63 +fun ContactPagerScreen(
64 + viewModel: ContactPagerViewModel,
65 + onBack: () -> Unit,
66 + onEdit: (contactId: Long) -> Unit,
67 + onDuplicates: (contactId: Long) -> Unit,
68 + modifier: Modifier = Modifier,
69 +) {
70 + val contacts by viewModel.contacts.collectAsState()
71 + val duplicateCountById by viewModel.duplicateCountById.collectAsState()
72 +
73 + Column(
74 + modifier
75 + .fillMaxSize()
76 + .background(Fond),
77 + ) {
78 + if (contacts.isEmpty()) {
79 + FicheTopBar(
80 + onBack = onBack,
81 + onEdit = null,
82 + duplicateCount = 0,
83 + onDuplicates = null,
84 + )
85 + Box(
86 + Modifier.fillMaxSize(),
87 + contentAlignment = Alignment.Center,
88 + ) {
89 + Text(stringResource(R.string.carnet_vide), color = TexteFaible)
90 + }
91 + return@Column
92 + }
93 +
94 + val initialPage = viewModel.initialPageIndex(contacts)
95 + val pagerState = rememberPagerState(
96 + initialPage = initialPage,
97 + pageCount = { contacts.size },
98 + )
99 + val current = contacts.getOrNull(pagerState.currentPage) ?: contacts[initialPage]
100 + val dupCount = duplicateCountById[current.id] ?: 0
101 +
102 + FicheTopBar(
103 + onBack = onBack,
104 + onEdit = { onEdit(current.id) },
105 + duplicateCount = dupCount,
106 + onDuplicates = if (dupCount > 0) {
107 + { onDuplicates(current.id) }
108 + } else {
109 + null
110 + },
111 + )
112 +
113 + HorizontalPager(
114 + state = pagerState,
115 + modifier = Modifier.fillMaxSize(),
116 + ) { page ->
117 + ContactFichePage(contact = contacts[page])
118 + }
119 + }
120 +}
121 +
122 +@Composable
123 +private fun FicheTopBar(
124 + onBack: () -> Unit,
125 + onEdit: (() -> Unit)?,
126 + duplicateCount: Int,
127 + onDuplicates: (() -> Unit)?,
128 +) {
129 + Row(
130 + Modifier
131 + .fillMaxWidth()
132 + .background(Surface)
133 + .padding(horizontal = 4.dp, vertical = 4.dp),
134 + verticalAlignment = Alignment.CenterVertically,
135 + ) {
136 + IconButton(onClick = onBack) {
137 + Icon(
138 + Icons.AutoMirrored.Outlined.ArrowBack,
139 + contentDescription = stringResource(R.string.scan_retour),
140 + )
141 + }
142 + Spacer(Modifier.weight(1f))
143 + if (onDuplicates != null && duplicateCount > 0) {
144 + TextButton(onClick = onDuplicates, shape = Square) {
145 + Text(
146 + stringResource(R.string.fiche_doublons, duplicateCount),
147 + color = Encre,
148 + )
149 + }
150 + }
151 + if (onEdit != null) {
152 + TextButton(onClick = onEdit, shape = Square) {
153 + Text(stringResource(R.string.fiche_editer), color = Encre)
154 + }
155 + }
156 + }
157 +}
158 +
159 +@Composable
160 +private fun ContactFichePage(
161 + contact: CrmContactEntity,
162 + modifier: Modifier = Modifier,
163 +) {
164 + val context = LocalContext.current
165 + val scope = rememberCoroutineScope()
166 + val tracker = remember(contact.id) { TapGestureTracker() }
167 +
168 + fun openEmail() {
169 + val email = contact.emails.firstOrNull()?.takeIf { it.isNotBlank() } ?: return
170 + try {
171 + context.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")))
172 + } catch (_: ActivityNotFoundException) {
173 + // no-op
174 + }
175 + }
176 +
177 + fun openDial() {
178 + val phone = contact.phones.firstOrNull()?.takeIf { it.isNotBlank() } ?: return
179 + try {
180 + context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")))
181 + } catch (_: ActivityNotFoundException) {
182 + // no-op
183 + }
184 + }
185 +
186 + Column(
187 + modifier
188 + .fillMaxSize()
189 + .pointerInput(contact.id, contact.emails, contact.phones) {
190 + // onDoubleTap → mailto (différé 400 ms) ; 3ᵉ tap dans la fenêtre → dial.
191 + detectTapGestures(
192 + onDoubleTap = {
193 + tracker.onDoubleTapArm(scope = scope, onDouble = { openEmail() })
194 + },
195 + onTap = {
196 + tracker.onTap(onTriple = { openDial() })
197 + },
198 + )
199 + }
200 + .verticalScroll(rememberScrollState())
201 + .padding(horizontal = 18.dp, vertical = 12.dp),
202 + verticalArrangement = Arrangement.spacedBy(12.dp),
203 + ) {
204 + PathImage(
205 + path = contact.profileImagePath,
206 + contentDescription = stringResource(R.string.fiche_photo_profil),
207 + modifier = Modifier
208 + .size(96.dp)
209 + .border(1.dp, Bordure, Square),
210 + )
211 +
212 + Text(
213 + text = contactDisplayName(contact),
214 + style = MaterialTheme.typography.titleLarge,
215 + color = Encre,
216 + )
217 +
218 + FieldBlock(stringResource(R.string.scan_champ_prenom), contact.firstName)
219 + FieldBlock(stringResource(R.string.scan_champ_nom_famille), contact.lastName)
220 + FieldBlock(stringResource(R.string.scan_champ_societe), contact.company)
221 + FieldBlock(stringResource(R.string.scan_champ_poste), contact.jobTitle)
222 + FieldBlock(
223 + stringResource(R.string.scan_champ_tel),
224 + contact.phones.filter { it.isNotBlank() }.joinToString("\n").ifBlank { null },
225 + )
226 + FieldBlock(
227 + stringResource(R.string.scan_champ_email),
228 + contact.emails.filter { it.isNotBlank() }.joinToString("\n").ifBlank { null },
229 + )
230 + FieldBlock(stringResource(R.string.scan_champ_site), contact.website)
231 + FieldBlock(stringResource(R.string.scan_champ_adresse), contact.address)
232 +
233 + if (!contact.cardImagePath.isNullOrBlank()) {
234 + HorizontalDivider(color = Bordure, thickness = 1.dp)
235 + PathImage(
236 + path = contact.cardImagePath,
237 + contentDescription = stringResource(R.string.fiche_photo_carte),
238 + modifier = Modifier
239 + .fillMaxWidth()
240 + .height(200.dp)
241 + .border(1.dp, Bordure, Square),
242 + contentScale = ContentScale.Fit,
243 + )
244 + }
245 +
246 + if (!contact.notes.isNullOrBlank()) {
247 + HorizontalDivider(color = Bordure, thickness = 1.dp)
248 + FieldBlock(stringResource(R.string.scan_champ_note), contact.notes)
249 + }
250 + }
251 +}
252 +
253 +/**
254 + * Compteur pour triple-tap (timeout [TapTimeoutMs]).
255 + * Le double-tap mail reste sur [detectTapGestures] `onDoubleTap`.
256 + *
257 + * Avec `onDoubleTap` présent, Compose n'émet `onTap` que pour les taps isolés ;
258 + * pour un vrai 1-2-3 rapide on compte aussi via [onDoubleTapSeen] :
259 + * double → arme une fenêtre, 3ᵉ tap (`onTap` suivant) → dial et annule le mail.
260 + */
261 +private class TapGestureTracker {
262 + private var armedForTriple = false
263 + private var job: Job? = null
264 +
265 + fun onDoubleTapArm(scope: kotlinx.coroutines.CoroutineScope, onDouble: () -> Unit) {
266 + armedForTriple = true
267 + job?.cancel()
268 + job = scope.launch {
269 + delay(TapTimeoutMs)
270 + if (armedForTriple) {
271 + armedForTriple = false
272 + onDouble()
273 + }
274 + }
275 + }
276 +
277 + fun onTap(onTriple: () -> Unit) {
278 + if (armedForTriple) {
279 + armedForTriple = false
280 + job?.cancel()
281 + onTriple()
282 + }
283 + }
284 +}
285 +
286 +@Composable
287 +private fun FieldBlock(label: String, value: String?) {
288 + if (value.isNullOrBlank()) return
289 + Column(Modifier.fillMaxWidth()) {
290 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
291 + Text(value, style = MaterialTheme.typography.bodyLarge, color = Encre)
292 + }
293 +}
294 +
295 +@Composable
296 +private fun PathImage(
297 + path: String?,
298 + contentDescription: String,
299 + modifier: Modifier = Modifier,
300 + contentScale: ContentScale = ContentScale.Crop,
301 +) {
302 + val bitmap: ImageBitmap? = remember(path) {
303 + path?.takeIf { it.isNotBlank() }?.let { p ->
304 + BitmapFactory.decodeFile(p)?.asImageBitmap()
305 + }
306 + }
307 + if (bitmap != null) {
308 + Image(
309 + bitmap = bitmap,
310 + contentDescription = contentDescription,
311 + modifier = modifier,
312 + contentScale = contentScale,
313 + )
314 + }
315 +}
316 +
317 +private fun contactDisplayName(c: CrmContactEntity): String {
318 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
319 + val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim()
320 + if (composed.isNotEmpty()) return composed
321 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
322 + return "—"
323 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+42 -0
@@ -0,0 +1,42 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.AlphabetIndex
6 +import fr.ebii.card2vcf.crm.ContactSort
7 +import fr.ebii.card2vcf.crm.DuplicateDetector
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.CrmContactEntity
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.flow.SharingStarted
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.map
14 +import kotlinx.coroutines.flow.mapLatest
15 +import kotlinx.coroutines.flow.stateIn
16 +
17 +@OptIn(ExperimentalCoroutinesApi::class)
18 +class ContactPagerViewModel(
19 + repository: ContactRepository,
20 + val initialContactId: Long,
21 + val sort: ContactSort,
22 +) : ViewModel() {
23 +
24 + val contacts: StateFlow<List<CrmContactEntity>> = repository.observeAll()
25 + .map { list -> AlphabetIndex.group(list, sort).values.flatten() }
26 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
27 +
28 + val duplicateCountById: StateFlow<Map<Long, Int>> = repository.observeAll()
29 + .mapLatest { list ->
30 + val exclusions = repository.getExclusions()
31 + val clusters = DuplicateDetector.clusters(list, exclusions)
32 + list.associate { c ->
33 + c.id to DuplicateDetector.duplicateCount(c.id, clusters)
34 + }
35 + }
36 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
37 +
38 + fun initialPageIndex(list: List<CrmContactEntity>): Int {
39 + val idx = list.indexOfFirst { it.id == initialContactId }
40 + return if (idx >= 0) idx else 0
41 + }
42 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+19 -2
@@ -15,6 +15,7 @@ import androidx.navigation.compose.composable
15 15 import androidx.navigation.compose.rememberNavController
16 16 import androidx.navigation.navArgument
17 17 import fr.ebii.card2vcf.contact.ContactCard
18 +import fr.ebii.card2vcf.crm.ContactSort
18 19 import fr.ebii.card2vcf.data.ContactRepository
19 20 import fr.ebii.card2vcf.ocr.TesseractOcrEngine
20 21 import fr.ebii.card2vcf.scan.OpenCvScanEngine
@@ -22,6 +23,8 @@ import fr.ebii.card2vcf.ui.ScanCarteScreen
22 23 import fr.ebii.card2vcf.ui.ScanCarteViewModel
23 24 import fr.ebii.card2vcf.ui.carnet.CarnetScreen
24 25 import fr.ebii.card2vcf.ui.carnet.CarnetViewModel
26 +import fr.ebii.card2vcf.ui.contact.ContactPagerScreen
27 +import fr.ebii.card2vcf.ui.contact.ContactPagerViewModel
25 28
26 29 object Routes {
27 30 const val Carnet = "carnet"
@@ -88,8 +91,22 @@ fun Card2vcfNavHost(
88 91 },
89 92 ),
90 93 ) { entry ->
91 - val id = entry.arguments?.getLong("contactId")
92 - PlaceholderRoute("Fiche #$id")
94 + val id = entry.arguments?.getLong("contactId") ?: return@composable
95 + val sortName = entry.arguments?.getString("sort")
96 + val sort = sortName
97 + ?.let { runCatching { ContactSort.valueOf(it) }.getOrNull() }
98 + ?: ContactSort.LAST_NAME
99 + val pagerVm = remember(repository, id, sort) {
100 + ContactPagerViewModel(repository, id, sort)
101 + }
102 + ContactPagerScreen(
103 + viewModel = pagerVm,
104 + onBack = { navController.popBackStack() },
105 + onEdit = { contactId -> navController.navigate(Routes.edit(contactId)) },
106 + onDuplicates = { contactId ->
107 + navController.navigate(Routes.duplicates(contactId))
108 + },
109 + )
93 110 }
94 111 composable(
95 112 route = Routes.Edit,
M android/app/src/main/res/values-en/strings.xml
+5 -0
@@ -37,4 +37,9 @@
37 37 <string name="carnet_fab_manuel">Manual</string>
38 38 <string name="carnet_fab_import">Import VCF</string>
39 39 <string name="carnet_vide">No contacts</string>
40 +
41 + <string name="fiche_editer">Edit</string>
42 + <string name="fiche_doublons">%d duplicate(s)</string>
43 + <string name="fiche_photo_profil">Profile photo</string>
44 + <string name="fiche_photo_carte">Card photo</string>
40 45 </resources>
M android/app/src/main/res/values/strings.xml
+5 -0
@@ -37,4 +37,9 @@
37 37 <string name="carnet_fab_manuel">Manuel</string>
38 38 <string name="carnet_fab_import">Import VCF</string>
39 39 <string name="carnet_vide">Aucun contact</string>
40 +
41 + <string name="fiche_editer">Éditer</string>
42 + <string name="fiche_doublons">%d doublon(s)</string>
43 + <string name="fiche_photo_profil">Photo de profil</string>
44 + <string name="fiche_photo_carte">Photo de la carte</string>
40 45 </resources>