feat(ui): carnet tri abcédaire recherche et menu création

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

d0a966aedbdb7addc0758529fd66ac485c01c300

1 parent(s)

7 fichiers modifiés +440 -9
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+6 -1
@@ -24,7 +24,12 @@ class ContactRepository(
24 24 suspend fun search(query: String): List<CrmContactEntity> {
25 25 val q = query.trim()
26 26 if (q.isEmpty()) return dao.getAll()
27 - val token = q.split(Regex("\\s+")).joinToString(" ") { "$it*" }
27 + // Tokens alphanumériques seulement — évite les crashes FTS MATCH sur `"` `*` `:` etc.
28 + val tokens = q.split(Regex("\\s+"))
29 + .map { token -> token.filter { it.isLetterOrDigit() } }
30 + .filter { it.isNotEmpty() }
31 + if (tokens.isEmpty()) return emptyList()
32 + val token = tokens.joinToString(" ") { "$it*" }
28 33 val ids = dao.searchIds(token)
29 34 return ids.mapNotNull { dao.getById(it) }
30 35 }
A android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/AlphabetRail.kt
+51 -0
@@ -0,0 +1,51 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import androidx.compose.foundation.clickable
4 +import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Column
6 +import androidx.compose.foundation.layout.fillMaxHeight
7 +import androidx.compose.foundation.layout.padding
8 +import androidx.compose.foundation.layout.width
9 +import androidx.compose.foundation.rememberScrollState
10 +import androidx.compose.foundation.verticalScroll
11 +import androidx.compose.material3.MaterialTheme
12 +import androidx.compose.material3.Text
13 +import androidx.compose.runtime.Composable
14 +import androidx.compose.ui.Alignment
15 +import androidx.compose.ui.Modifier
16 +import androidx.compose.ui.unit.dp
17 +import fr.ebii.card2vcf.ui.theme.Body
18 +import fr.ebii.card2vcf.ui.theme.Ink
19 +
20 +private val RailLetters: List<Char> = ('A'..'Z').toList() + '#'
21 +
22 +@Composable
23 +fun AlphabetRail(
24 + presentLetters: Set<Char>,
25 + onLetter: (Char) -> Unit,
26 + modifier: Modifier = Modifier,
27 +) {
28 + Column(
29 + modifier = modifier
30 + .fillMaxHeight()
31 + .width(28.dp)
32 + .verticalScroll(rememberScrollState())
33 + .padding(vertical = 4.dp),
34 + horizontalAlignment = Alignment.CenterHorizontally,
35 + verticalArrangement = Arrangement.SpaceEvenly,
36 + ) {
37 + for (letter in RailLetters) {
38 + val present = letter in presentLetters
39 + Text(
40 + text = letter.toString(),
41 + style = MaterialTheme.typography.labelSmall,
42 + color = if (present) Ink else Body,
43 + modifier = Modifier
44 + .padding(vertical = 1.dp)
45 + .then(
46 + if (present) Modifier.clickable { onLetter(letter) } else Modifier,
47 + ),
48 + )
49 + }
50 + }
51 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+286 -4
@@ -1,22 +1,304 @@
1 1 package fr.ebii.card2vcf.ui.carnet
2 2
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.clickable
3 5 import androidx.compose.foundation.layout.Box
6 +import androidx.compose.foundation.layout.Column
7 +import androidx.compose.foundation.layout.Row
8 +import androidx.compose.foundation.layout.Spacer
4 9 import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.lazy.LazyColumn
14 +import androidx.compose.foundation.lazy.LazyListState
15 +import androidx.compose.foundation.lazy.items
16 +import androidx.compose.foundation.lazy.rememberLazyListState
17 +import androidx.compose.foundation.shape.RoundedCornerShape
18 +import androidx.compose.material.icons.Icons
19 +import androidx.compose.material.icons.filled.Add
20 +import androidx.compose.material3.DropdownMenu
21 +import androidx.compose.material3.DropdownMenuItem
22 +import androidx.compose.material3.FloatingActionButton
23 +import androidx.compose.material3.HorizontalDivider
24 +import androidx.compose.material3.Icon
25 +import androidx.compose.material3.MaterialTheme
26 +import androidx.compose.material3.OutlinedTextField
27 +import androidx.compose.material3.OutlinedTextFieldDefaults
28 +import androidx.compose.material3.Scaffold
5 29 import androidx.compose.material3.Text
30 +import androidx.compose.material3.TextButton
6 31 import androidx.compose.runtime.Composable
32 +import androidx.compose.runtime.collectAsState
33 +import androidx.compose.runtime.getValue
34 +import androidx.compose.runtime.mutableStateOf
35 +import androidx.compose.runtime.remember
36 +import androidx.compose.runtime.rememberCoroutineScope
37 +import androidx.compose.runtime.setValue
7 38 import androidx.compose.ui.Alignment
8 39 import androidx.compose.ui.Modifier
40 +import androidx.compose.ui.res.stringResource
41 +import androidx.compose.ui.unit.dp
42 +import fr.ebii.card2vcf.R
43 +import fr.ebii.card2vcf.crm.ContactSort
44 +import fr.ebii.card2vcf.data.CrmContactEntity
45 +import fr.ebii.card2vcf.ui.theme.Bordure
46 +import fr.ebii.card2vcf.ui.theme.Fond
47 +import fr.ebii.card2vcf.ui.theme.Ink
48 +import fr.ebii.card2vcf.ui.theme.OnPrimary
49 +import fr.ebii.card2vcf.ui.theme.TexteFaible
50 +import kotlinx.coroutines.launch
51 +
52 +private val Square = RoundedCornerShape(0.dp)
9 53
10 -/** Stub — liste / abcédaire / FAB en Task 7. */
11 54 @Composable
12 55 fun CarnetScreen(
13 56 viewModel: CarnetViewModel,
57 + onOpenContact: (contactId: Long, sort: ContactSort) -> Unit,
58 + onScan: () -> Unit,
59 + onManual: () -> Unit,
60 + onImportVcf: () -> Unit,
61 + modifier: Modifier = Modifier,
62 +) {
63 + val sort by viewModel.sort.collectAsState()
64 + val query by viewModel.query.collectAsState()
65 + val sections by viewModel.sections.collectAsState()
66 + val letterOffsets by viewModel.letterOffsets.collectAsState()
67 + val listState = rememberLazyListState()
68 + val scope = rememberCoroutineScope()
69 + var sortMenuOpen by remember { mutableStateOf(false) }
70 + var fabMenuOpen by remember { mutableStateOf(false) }
71 + val showRail = query.isBlank()
72 + val presentLetters = remember(sections) { sections.map { it.first }.toSet() }
73 + val isEmpty = sections.isEmpty() || sections.all { it.second.isEmpty() }
74 +
75 + Scaffold(
76 + modifier = modifier.fillMaxSize().background(Fond),
77 + containerColor = Fond,
78 + floatingActionButton = {
79 + Box {
80 + FloatingActionButton(
81 + onClick = { fabMenuOpen = true },
82 + shape = Square,
83 + containerColor = Ink,
84 + contentColor = OnPrimary,
85 + ) {
86 + Icon(
87 + Icons.Filled.Add,
88 + contentDescription = stringResource(R.string.carnet_fab_ajouter),
89 + )
90 + }
91 + DropdownMenu(
92 + expanded = fabMenuOpen,
93 + onDismissRequest = { fabMenuOpen = false },
94 + shape = Square,
95 + ) {
96 + DropdownMenuItem(
97 + text = { Text(stringResource(R.string.carnet_fab_scanner)) },
98 + onClick = {
99 + fabMenuOpen = false
100 + onScan()
101 + },
102 + )
103 + DropdownMenuItem(
104 + text = { Text(stringResource(R.string.carnet_fab_manuel)) },
105 + onClick = {
106 + fabMenuOpen = false
107 + onManual()
108 + },
109 + )
110 + DropdownMenuItem(
111 + text = { Text(stringResource(R.string.carnet_fab_import)) },
112 + onClick = {
113 + fabMenuOpen = false
114 + onImportVcf()
115 + },
116 + )
117 + }
118 + }
119 + },
120 + ) { padding ->
121 + Column(
122 + Modifier
123 + .fillMaxSize()
124 + .padding(padding)
125 + .padding(horizontal = 18.dp),
126 + ) {
127 + Row(
128 + Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp),
129 + verticalAlignment = Alignment.CenterVertically,
130 + ) {
131 + Text(
132 + text = stringResource(R.string.app_name).uppercase(),
133 + style = MaterialTheme.typography.titleLarge,
134 + color = Ink,
135 + modifier = Modifier.weight(1f),
136 + )
137 + Box {
138 + TextButton(onClick = { sortMenuOpen = true }, shape = Square) {
139 + Text(sortLabel(sort), color = Ink)
140 + }
141 + DropdownMenu(
142 + expanded = sortMenuOpen,
143 + onDismissRequest = { sortMenuOpen = false },
144 + shape = Square,
145 + ) {
146 + ContactSort.entries.forEach { option ->
147 + DropdownMenuItem(
148 + text = { Text(sortLabel(option)) },
149 + onClick = {
150 + sortMenuOpen = false
151 + viewModel.setSort(option)
152 + },
153 + )
154 + }
155 + }
156 + }
157 + }
158 +
159 + OutlinedTextField(
160 + value = query,
161 + onValueChange = viewModel::setQuery,
162 + modifier = Modifier.fillMaxWidth(),
163 + singleLine = true,
164 + shape = Square,
165 + placeholder = {
166 + Text(stringResource(R.string.carnet_recherche), color = TexteFaible)
167 + },
168 + colors = OutlinedTextFieldDefaults.colors(
169 + focusedBorderColor = Ink,
170 + unfocusedBorderColor = Bordure,
171 + focusedTextColor = Ink,
172 + unfocusedTextColor = Ink,
173 + cursorColor = Ink,
174 + ),
175 + )
176 +
177 + Spacer(Modifier.height(8.dp))
178 +
179 + if (isEmpty) {
180 + Box(
181 + Modifier.weight(1f).fillMaxWidth(),
182 + contentAlignment = Alignment.Center,
183 + ) {
184 + Text(
185 + stringResource(R.string.carnet_vide),
186 + color = TexteFaible,
187 + style = MaterialTheme.typography.bodyLarge,
188 + )
189 + }
190 + } else {
191 + Row(Modifier.weight(1f).fillMaxWidth()) {
192 + ContactList(
193 + sections = sections,
194 + listState = listState,
195 + onOpenContact = { id -> onOpenContact(id, sort) },
196 + modifier = Modifier.weight(1f),
197 + )
198 + if (showRail) {
199 + AlphabetRail(
200 + presentLetters = presentLetters,
201 + onLetter = { letter ->
202 + val offset = letterOffsets[letter] ?: return@AlphabetRail
203 + scope.launch {
204 + listState.animateScrollToItem(offset)
205 + }
206 + },
207 + )
208 + }
209 + }
210 + }
211 + }
212 + }
213 +}
214 +
215 +@Composable
216 +private fun ContactList(
217 + sections: List<Pair<Char, List<CrmContactEntity>>>,
218 + listState: LazyListState,
219 + onOpenContact: (Long) -> Unit,
14 220 modifier: Modifier = Modifier,
15 221 ) {
16 - Box(
222 + LazyColumn(
223 + state = listState,
17 224 modifier = modifier.fillMaxSize(),
18 - contentAlignment = Alignment.Center,
19 225 ) {
20 - Text("Carnet")
226 + sections.forEach { (letter, contacts) ->
227 + item(key = "header-$letter") {
228 + Text(
229 + text = letter.toString(),
230 + style = MaterialTheme.typography.labelMedium,
231 + color = TexteFaible,
232 + modifier = Modifier
233 + .fillMaxWidth()
234 + .background(Fond)
235 + .padding(vertical = 6.dp),
236 + )
237 + }
238 + items(contacts, key = { it.id }) { contact ->
239 + ContactRow(
240 + contact = contact,
241 + onClick = { onOpenContact(contact.id) },
242 + )
243 + }
244 + }
245 + }
246 +}
247 +
248 +@Composable
249 +private fun ContactRow(
250 + contact: CrmContactEntity,
251 + onClick: () -> Unit,
252 +) {
253 + Column(
254 + Modifier
255 + .fillMaxWidth()
256 + .clickable(onClick = onClick),
257 + ) {
258 + Column(Modifier.padding(vertical = 10.dp)) {
259 + Text(
260 + text = contactDisplayName(contact),
261 + style = MaterialTheme.typography.bodyLarge,
262 + color = Ink,
263 + )
264 + val subtitle = contactSubtitle(contact)
265 + if (subtitle != null) {
266 + Text(
267 + text = subtitle,
268 + style = MaterialTheme.typography.bodyMedium,
269 + color = TexteFaible,
270 + )
271 + }
272 + }
273 + HorizontalDivider(color = Bordure, thickness = 1.dp)
274 + }
275 +}
276 +
277 +@Composable
278 +private fun sortLabel(sort: ContactSort): String = stringResource(
279 + when (sort) {
280 + ContactSort.LAST_NAME -> R.string.carnet_tri_nom
281 + ContactSort.FIRST_NAME -> R.string.carnet_tri_prenom
282 + ContactSort.COMPANY -> R.string.carnet_tri_entreprise
283 + ContactSort.CREATED_AT -> R.string.carnet_tri_date
284 + },
285 +)
286 +
287 +private fun contactDisplayName(c: CrmContactEntity): String {
288 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
289 + val composed = listOfNotNull(c.firstName, c.lastName)
290 + .joinToString(" ")
291 + .trim()
292 + if (composed.isNotEmpty()) return composed
293 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
294 + return "—"
295 +}
296 +
297 +private fun contactSubtitle(c: CrmContactEntity): String? {
298 + val name = c.fullName?.takeIf { it.isNotBlank() }
299 + ?: listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim().ifEmpty { null }
300 + return when {
301 + name != null -> c.company?.takeIf { it.isNotBlank() }
302 + else -> c.jobTitle?.takeIf { it.isNotBlank() }
21 303 }
22 304 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt
+66 -3
@@ -1,16 +1,79 @@
1 1 package fr.ebii.card2vcf.ui.carnet
2 2
3 3 import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.AlphabetIndex
6 +import fr.ebii.card2vcf.crm.ContactSort
4 7 import fr.ebii.card2vcf.data.ContactRepository
5 8 import fr.ebii.card2vcf.data.CrmContactEntity
9 +import kotlinx.coroutines.ExperimentalCoroutinesApi
6 10 import kotlinx.coroutines.flow.MutableStateFlow
11 +import kotlinx.coroutines.flow.SharingStarted
7 12 import kotlinx.coroutines.flow.StateFlow
8 13 import kotlinx.coroutines.flow.asStateFlow
14 +import kotlinx.coroutines.flow.combine
15 +import kotlinx.coroutines.flow.flatMapLatest
16 +import kotlinx.coroutines.flow.flow
17 +import kotlinx.coroutines.flow.stateIn
9 18
10 -/** Stub ViewModel — UI carnet complète en Task 7. */
19 +@OptIn(ExperimentalCoroutinesApi::class)
11 20 class CarnetViewModel(
12 21 private val repository: ContactRepository,
13 22 ) : ViewModel() {
14 - private val _contacts = MutableStateFlow<List<CrmContactEntity>>(emptyList())
15 - val contacts: StateFlow<List<CrmContactEntity>> = _contacts.asStateFlow()
23 +
24 + private val _sort = MutableStateFlow(ContactSort.LAST_NAME)
25 + val sort: StateFlow<ContactSort> = _sort.asStateFlow()
26 +
27 + private val _query = MutableStateFlow("")
28 + val query: StateFlow<String> = _query.asStateFlow()
29 +
30 + private val contactsFlow = _query.flatMapLatest { q ->
31 + if (q.isBlank()) {
32 + repository.observeAll()
33 + } else {
34 + flow {
35 + emit(repository.search(q))
36 + }
37 + }
38 + }
39 +
40 + val contacts: StateFlow<List<CrmContactEntity>> = contactsFlow
41 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
42 +
43 + val sections: StateFlow<List<Pair<Char, List<CrmContactEntity>>>> =
44 + combine(contacts, _sort) { list, sort ->
45 + AlphabetIndex.group(list, sort).toList()
46 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
47 +
48 + val letterOffsets: StateFlow<Map<Char, Int>> =
49 + sections.combine(_sort) { sectionList, _ ->
50 + letterOffsetsOf(sectionList)
51 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
52 +
53 + fun setQuery(value: String) {
54 + _query.value = value
55 + }
56 +
57 + fun setSort(value: ContactSort) {
58 + _sort.value = value
59 + }
60 +
61 + fun cycleSort() {
62 + val values = ContactSort.entries
63 + val next = (values.indexOf(_sort.value) + 1) % values.size
64 + _sort.value = values[next]
65 + }
66 +
67 + companion object {
68 + /** Indices LazyColumn : en-tête de section + lignes contact. */
69 + fun letterOffsetsOf(sections: List<Pair<Char, List<CrmContactEntity>>>): Map<Char, Int> {
70 + val map = LinkedHashMap<Char, Int>()
71 + var index = 0
72 + for ((letter, items) in sections) {
73 + map[letter] = index
74 + index += 1 + items.size
75 + }
76 + return map
77 + }
78 + }
16 79 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+9 -1
@@ -67,7 +67,15 @@ fun Card2vcfNavHost(
67 67 modifier = modifier,
68 68 ) {
69 69 composable(Routes.Carnet) {
70 - CarnetScreen(viewModel = carnetVm)
70 + CarnetScreen(
71 + viewModel = carnetVm,
72 + onOpenContact = { id, sort ->
73 + navController.navigate(Routes.fiche(id, sort.name))
74 + },
75 + onScan = { navController.navigate(Routes.scan()) },
76 + onManual = { navController.navigate(Routes.Manual) },
77 + onImportVcf = { navController.navigate(Routes.ImportVcf) },
78 + )
71 79 }
72 80 composable(
73 81 route = Routes.Fiche,
M android/app/src/main/res/values-en/strings.xml
+11 -0
@@ -26,4 +26,15 @@
26 26 <string name="scan_champ_site">Website</string>
27 27 <string name="scan_champ_adresse">Address</string>
28 28 <string name="scan_champ_note">Note</string>
29 +
30 + <string name="carnet_recherche">Search…</string>
31 + <string name="carnet_tri_nom">Last name</string>
32 + <string name="carnet_tri_prenom">First name</string>
33 + <string name="carnet_tri_entreprise">Company</string>
34 + <string name="carnet_tri_date">Date</string>
35 + <string name="carnet_fab_ajouter">Add contact</string>
36 + <string name="carnet_fab_scanner">Scan</string>
37 + <string name="carnet_fab_manuel">Manual</string>
38 + <string name="carnet_fab_import">Import VCF</string>
39 + <string name="carnet_vide">No contacts</string>
29 40 </resources>
M android/app/src/main/res/values/strings.xml
+11 -0
@@ -26,4 +26,15 @@
26 26 <string name="scan_champ_site">Site web</string>
27 27 <string name="scan_champ_adresse">Adresse</string>
28 28 <string name="scan_champ_note">Note</string>
29 +
30 + <string name="carnet_recherche">Rechercher…</string>
31 + <string name="carnet_tri_nom">Nom</string>
32 + <string name="carnet_tri_prenom">Prénom</string>
33 + <string name="carnet_tri_entreprise">Entreprise</string>
34 + <string name="carnet_tri_date">Date</string>
35 + <string name="carnet_fab_ajouter">Ajouter un contact</string>
36 + <string name="carnet_fab_scanner">Scanner</string>
37 + <string name="carnet_fab_manuel">Manuel</string>
38 + <string name="carnet_fab_import">Import VCF</string>
39 + <string name="carnet_vide">Aucun contact</string>
29 40 </resources>