actualisation du client et du serveur davantage VCF creer projet client design

EBO <eric.bouhana@softalys.com> committé le 2026-09-12 11:46

b75b90ddb6fbd57d29325326abb3136c998924c5

1 parent(s)

41 fichiers modifiés +1331 -273
M android/app/build.gradle.kts
+1 -1
@@ -24,7 +24,7 @@ android {
24 24 compileSdk = 35
25 25 defaultConfig {
26 26 applicationId = "fr.ebii.piclead"
27 - minSdk = 31
27 + minSdk = 30
28 28 targetSdk = 35
29 29 versionCode = 1
30 30 versionName = "0.1.0"
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+10 -0
@@ -83,6 +83,16 @@ class ContactRepository(
83 83 createdAt = existing.createdAt,
84 84 cardImagePath = existing.cardImagePath,
85 85 profileImagePath = existing.profileImagePath,
86 + serverId = existing.serverId,
87 + statut = existing.statut,
88 + etape = existing.etape,
89 + tags = existing.tags,
90 + // Si le nom d'entreprise change, on invalide le lien : le serveur re-résoudra par nom.
91 + entrepriseServerId = if (card.company?.trim() == existing.company?.trim()) {
92 + existing.entrepriseServerId
93 + } else {
94 + null
95 + },
86 96 )
87 97 if (cardBitmap != null) {
88 98 updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+12 -1
@@ -5,6 +5,8 @@ import androidx.room.Database
5 5 import androidx.room.Room
6 6 import androidx.room.RoomDatabase
7 7 import androidx.room.TypeConverters
8 +import androidx.room.migration.Migration
9 +import androidx.sqlite.db.SupportSQLiteDatabase
8 10 import fr.ebii.card2vcf.sync.SyncMetaDao
9 11 import fr.ebii.card2vcf.sync.SyncMetaEntity
10 12 import fr.ebii.card2vcf.sync.SyncOpDao
@@ -26,7 +28,7 @@ import fr.ebii.card2vcf.sync.SyncOpEntity
26 28 ReservationEntity::class,
27 29 IndisponibiliteEntity::class,
28 30 ],
29 - version = 3,
31 + version = 4,
30 32 exportSchema = false,
31 33 )
32 34 @TypeConverters(Converters::class)
@@ -46,8 +48,16 @@ abstract class CrmDatabase : RoomDatabase() {
46 48 companion object {
47 49 @Volatile private var instance: CrmDatabase? = null
48 50
51 + /** v3→v4 : colonne `lastError` sur `sync_ops` (état d'échec de push par op). */
52 + val MIGRATION_3_4 = object : Migration(3, 4) {
53 + override fun migrate(db: SupportSQLiteDatabase) {
54 + db.execSQL("ALTER TABLE sync_ops ADD COLUMN lastError TEXT")
55 + }
56 + }
57 +
49 58 /**
50 59 * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3).
60 + * À partir de v3→v4 les migrations sont additives ; le fallback destructif reste en filet.
51 61 * Les tests utilisent Room.inMemoryDatabaseBuilder sans fallback destructif.
52 62 */
53 63 fun get(context: Context): CrmDatabase =
@@ -57,6 +67,7 @@ abstract class CrmDatabase : RoomDatabase() {
57 67 CrmDatabase::class.java,
58 68 "card2vcf-crm.db",
59 69 )
70 + .addMigrations(MIGRATION_3_4)
60 71 .fallbackToDestructiveMigration()
61 72 .build()
62 73 .also { instance = it }
M android/app/src/main/java/fr/ebii/card2vcf/data/WorkflowDao.kt
+4 -0
@@ -4,6 +4,7 @@ import androidx.room.Dao
4 4 import androidx.room.Insert
5 5 import androidx.room.OnConflictStrategy
6 6 import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
7 8
8 9 @Dao
9 10 interface WorkflowDao {
@@ -18,4 +19,7 @@ interface WorkflowDao {
18 19
19 20 @Query("SELECT * FROM workflows")
20 21 suspend fun listAll(): List<WorkflowEntity>
22 +
23 + @Query("SELECT * FROM workflows ORDER BY nom ASC")
24 + fun observeAll(): Flow<List<WorkflowEntity>>
21 25 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+2 -2
@@ -313,11 +313,11 @@ class AilianceApiClient(
313 313 }
314 314
315 315 private fun encodeQuery(value: String): String =
316 - java.net.URLEncoder.encode(value, Charsets.UTF_8)
316 + java.net.URLEncoder.encode(value, Charsets.UTF_8.name())
317 317
318 318 private fun encodePath(segment: String): String =
319 319 segment.split("/").joinToString("/") { part ->
320 - java.net.URLEncoder.encode(part, Charsets.UTF_8)
320 + java.net.URLEncoder.encode(part, Charsets.UTF_8.name())
321 321 }
322 322 }
323 323 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/ContactSyncMapper.kt
+3 -0
@@ -16,6 +16,9 @@ object ContactSyncMapper {
16 16 prenom = prenom,
17 17 nom = nom,
18 18 entrepriseId = entrepriseServerId?.takeIf { it.isNotBlank() },
19 + // Nom scanné transmis seulement sans lien serveur : le serveur résout ou crée.
20 + entrepriseNom = company?.trim()
21 + ?.takeIf { it.isNotEmpty() && entrepriseServerId.isNullOrBlank() },
19 22 fonction = jobTitle.orEmpty(),
20 23 emails = emails.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
21 24 telephones = phones.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+4 -0
@@ -179,6 +179,10 @@ class SyncEngine(
179 179 db.syncOpDao().deleteById(op.id)
180 180 } else if (outcome.conflict == null) {
181 181 failures += PushFailure(op.entityType, op.op, outcome.code, outcome.message)
182 + db.syncOpDao().markFailure(
183 + op.id,
184 + outcome.message ?: outcome.code?.let { "HTTP $it" } ?: "réseau",
185 + )
182 186 }
183 187 outcome.conflict?.let(conflicts::add)
184 188 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+9 -0
@@ -281,6 +281,7 @@ data class CreateContactRequest(
281 281 val prenom: String = "",
282 282 val nom: String = "",
283 283 val entrepriseId: String? = null,
284 + val entrepriseNom: String? = null,
284 285 val fonction: String = "",
285 286 val emails: List<ContactValeurDto> = emptyList(),
286 287 val telephones: List<ContactValeurDto> = emptyList(),
@@ -290,6 +291,14 @@ data class CreateContactRequest(
290 291 val tags: List<String> = emptyList(),
291 292 )
292 293
294 +/** Payload create projet (miroir serveur `ProjetInput`, body `/api/projets`). */
295 +@Serializable
296 +data class CreateProjetRequest(
297 + val nom: String,
298 + val description: String = "",
299 + val workflowId: String,
300 +)
301 +
293 302 /** Payload create/update RDV (miroir serveur, body `/api/rdv`). */
294 303 @Serializable
295 304 data class RdvUpsertRequest(
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpDao.kt
+7 -0
@@ -4,6 +4,7 @@ import androidx.room.Dao
4 4 import androidx.room.Insert
5 5 import androidx.room.OnConflictStrategy
6 6 import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
7 8
8 9 @Dao
9 10 interface SyncOpDao {
@@ -13,6 +14,12 @@ interface SyncOpDao {
13 14 @Query("SELECT * FROM sync_ops ORDER BY createdAt ASC")
14 15 suspend fun listAll(): List<SyncOpEntity>
15 16
17 + @Query("SELECT * FROM sync_ops ORDER BY createdAt ASC")
18 + fun observeAll(): Flow<List<SyncOpEntity>>
19 +
20 + @Query("UPDATE sync_ops SET attempts = attempts + 1, lastError = :error WHERE id = :id")
21 + suspend fun markFailure(id: Long, error: String?)
22 +
16 23 @Query("SELECT * FROM sync_ops WHERE id = :id")
17 24 suspend fun getById(id: Long): SyncOpEntity?
18 25
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpEntity.kt
+1 -0
@@ -13,4 +13,5 @@ data class SyncOpEntity(
13 13 val serverId: String? = null,
14 14 val createdAt: Long = 0L,
15 15 val attempts: Int = 0,
16 + val lastError: String? = null,
16 17 )
M android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftFields.kt
+71 -49
@@ -2,15 +2,16 @@ package fr.ebii.card2vcf.ui
2 2
3 3 import androidx.compose.foundation.layout.Arrangement
4 4 import androidx.compose.foundation.layout.Column
5 -import androidx.compose.foundation.layout.fillMaxWidth
6 -import androidx.compose.material3.OutlinedTextField
7 -import androidx.compose.material3.Text
5 +import androidx.compose.foundation.text.KeyboardOptions
8 6 import androidx.compose.runtime.Composable
9 7 import androidx.compose.ui.Modifier
10 8 import androidx.compose.ui.res.stringResource
9 +import androidx.compose.ui.text.input.ImeAction
10 +import androidx.compose.ui.text.input.KeyboardType
11 11 import androidx.compose.ui.unit.dp
12 12 import fr.ebii.card2vcf.R
13 13 import fr.ebii.card2vcf.contact.ContactCard
14 +import fr.ebii.card2vcf.ui.composants.ChampTexte
14 15
15 16 @Composable
16 17 fun ContactDraftFields(
@@ -19,55 +20,76 @@ fun ContactDraftFields(
19 20 modifier: Modifier = Modifier,
20 21 ) {
21 22 // Pas de verticalScroll ici : le parent ContactDraftScreen scrolle déjà.
23 + // Claviers adaptés + focus chaîné (ImeAction.Next), champs délimités via ChampTexte.
24 + val suivant = KeyboardOptions(imeAction = ImeAction.Next)
22 25 Column(
23 26 modifier,
24 27 verticalArrangement = Arrangement.spacedBy(10.dp),
25 28 ) {
26 - DraftField(stringResource(R.string.scan_champ_nom), card.fullName.orEmpty()) {
27 - onChange(card.copy(fullName = it.ifBlank { null }))
28 - }
29 - DraftField(stringResource(R.string.scan_champ_prenom), card.firstName.orEmpty()) {
30 - onChange(card.copy(firstName = it.ifBlank { null }))
31 - }
32 - DraftField(stringResource(R.string.scan_champ_nom_famille), card.lastName.orEmpty()) {
33 - onChange(card.copy(lastName = it.ifBlank { null }))
34 - }
35 - DraftField(stringResource(R.string.scan_champ_societe), card.company.orEmpty()) {
36 - onChange(card.copy(company = it.ifBlank { null }))
37 - }
38 - DraftField(stringResource(R.string.scan_champ_poste), card.jobTitle.orEmpty()) {
39 - onChange(card.copy(jobTitle = it.ifBlank { null }))
40 - }
41 - DraftField(stringResource(R.string.scan_champ_tel), card.phones.joinToString(", ")) {
42 - onChange(card.copy(phones = it.split(',', ';').map { p -> p.trim() }.filter { p -> p.isNotEmpty() }))
43 - }
44 - DraftField(stringResource(R.string.scan_champ_email), card.emails.joinToString(", ")) {
45 - onChange(card.copy(emails = it.split(',', ';').map { e -> e.trim() }.filter { e -> e.isNotEmpty() }))
46 - }
47 - DraftField(stringResource(R.string.scan_champ_site), card.website.orEmpty()) {
48 - onChange(card.copy(website = it.ifBlank { null }))
49 - }
50 - DraftField(stringResource(R.string.scan_champ_adresse), card.address.orEmpty()) {
51 - onChange(card.copy(address = it.ifBlank { null }))
52 - }
53 - DraftField(stringResource(R.string.scan_champ_note), card.note.orEmpty(), minLines = 2) {
54 - onChange(card.copy(note = it.ifBlank { null }))
55 - }
29 + ChampTexte(
30 + value = card.fullName.orEmpty(),
31 + onValueChange = { onChange(card.copy(fullName = it.ifBlank { null })) },
32 + label = stringResource(R.string.scan_champ_nom),
33 + keyboardOptions = suivant,
34 + )
35 + ChampTexte(
36 + value = card.firstName.orEmpty(),
37 + onValueChange = { onChange(card.copy(firstName = it.ifBlank { null })) },
38 + label = stringResource(R.string.scan_champ_prenom),
39 + keyboardOptions = suivant,
40 + )
41 + ChampTexte(
42 + value = card.lastName.orEmpty(),
43 + onValueChange = { onChange(card.copy(lastName = it.ifBlank { null })) },
44 + label = stringResource(R.string.scan_champ_nom_famille),
45 + keyboardOptions = suivant,
46 + )
47 + ChampTexte(
48 + value = card.company.orEmpty(),
49 + onValueChange = { onChange(card.copy(company = it.ifBlank { null })) },
50 + label = stringResource(R.string.scan_champ_societe),
51 + keyboardOptions = suivant,
52 + )
53 + ChampTexte(
54 + value = card.jobTitle.orEmpty(),
55 + onValueChange = { onChange(card.copy(jobTitle = it.ifBlank { null })) },
56 + label = stringResource(R.string.scan_champ_poste),
57 + keyboardOptions = suivant,
58 + )
59 + ChampTexte(
60 + value = card.phones.joinToString(", "),
61 + onValueChange = {
62 + onChange(card.copy(phones = it.split(',', ';').map { p -> p.trim() }.filter { p -> p.isNotEmpty() }))
63 + },
64 + label = stringResource(R.string.scan_champ_tel),
65 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
66 + )
67 + ChampTexte(
68 + value = card.emails.joinToString(", "),
69 + onValueChange = {
70 + onChange(card.copy(emails = it.split(',', ';').map { e -> e.trim() }.filter { e -> e.isNotEmpty() }))
71 + },
72 + label = stringResource(R.string.scan_champ_email),
73 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
74 + )
75 + ChampTexte(
76 + value = card.website.orEmpty(),
77 + onValueChange = { onChange(card.copy(website = it.ifBlank { null })) },
78 + label = stringResource(R.string.scan_champ_site),
79 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
80 + )
81 + ChampTexte(
82 + value = card.address.orEmpty(),
83 + onValueChange = { onChange(card.copy(address = it.ifBlank { null })) },
84 + label = stringResource(R.string.scan_champ_adresse),
85 + keyboardOptions = suivant,
86 + )
87 + ChampTexte(
88 + value = card.note.orEmpty(),
89 + onValueChange = { onChange(card.copy(note = it.ifBlank { null })) },
90 + label = stringResource(R.string.scan_champ_note),
91 + singleLine = false,
92 + minLines = 2,
93 + )
56 94 }
57 95 }
58 -
59 -@Composable
60 -private fun DraftField(
61 - label: String,
62 - value: String,
63 - minLines: Int = 1,
64 - onChange: (String) -> Unit,
65 -) {
66 - OutlinedTextField(
67 - value = value,
68 - onValueChange = onChange,
69 - label = { Text(label) },
70 - minLines = minLines,
71 - modifier = Modifier.fillMaxWidth(),
72 - )
73 -}
M android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftScreen.kt
+0 -6
@@ -33,7 +33,6 @@ import fr.ebii.card2vcf.ui.theme.OnPrimary
33 33 import fr.ebii.card2vcf.ui.theme.Surface
34 34 import fr.ebii.card2vcf.ui.theme.TexteFaible
35 35
36 -private val Square = RoundedCornerShape(0.dp)
37 36
38 37 @Composable
39 38 fun ContactDraftScreen(
@@ -74,8 +73,6 @@ fun ContactDraftScreen(
74 73 Button(
75 74 onClick = onSaveToCarnet,
76 75 modifier = Modifier.fillMaxWidth(),
77 - shape = Square,
78 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
79 76 ) {
80 77 Text(
81 78 stringResource(
@@ -86,21 +83,18 @@ fun ContactDraftScreen(
86 83 OutlinedButton(
87 84 onClick = onCreateContact,
88 85 modifier = Modifier.fillMaxWidth(),
89 - shape = Square,
90 86 ) {
91 87 Text(stringResource(R.string.scan_creer_contact))
92 88 }
93 89 OutlinedButton(
94 90 onClick = onExportVcf,
95 91 modifier = Modifier.fillMaxWidth(),
96 - shape = Square,
97 92 ) {
98 93 Text(stringResource(R.string.scan_exporter_vcf))
99 94 }
100 95 OutlinedButton(
101 96 onClick = onRetry,
102 97 modifier = Modifier.fillMaxWidth(),
103 - shape = Square,
104 98 ) {
105 99 Text(stringResource(R.string.scan_reprendre))
106 100 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/AlphabetRail.kt
+13 -6
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
4 4 import androidx.compose.foundation.layout.Arrangement
5 5 import androidx.compose.foundation.layout.Column
6 6 import androidx.compose.foundation.layout.fillMaxHeight
7 +import androidx.compose.foundation.layout.fillMaxWidth
7 8 import androidx.compose.foundation.layout.padding
8 9 import androidx.compose.foundation.layout.width
9 10 import androidx.compose.foundation.rememberScrollState
@@ -13,9 +14,8 @@ import androidx.compose.material3.Text
13 14 import androidx.compose.runtime.Composable
14 15 import androidx.compose.ui.Alignment
15 16 import androidx.compose.ui.Modifier
17 +import androidx.compose.ui.text.style.TextAlign
16 18 import androidx.compose.ui.unit.dp
17 -import fr.ebii.card2vcf.ui.theme.Body
18 -import fr.ebii.card2vcf.ui.theme.Ink
19 19
20 20 private val RailLetters: List<Char> = ('A'..'Z').toList() + '#'
21 21
@@ -28,7 +28,7 @@ fun AlphabetRail(
28 28 Column(
29 29 modifier = modifier
30 30 .fillMaxHeight()
31 - .width(28.dp)
31 + .width(32.dp)
32 32 .verticalScroll(rememberScrollState())
33 33 .padding(vertical = 4.dp),
34 34 horizontalAlignment = Alignment.CenterHorizontally,
@@ -39,12 +39,19 @@ fun AlphabetRail(
39 39 Text(
40 40 text = letter.toString(),
41 41 style = MaterialTheme.typography.labelSmall,
42 - color = if (present) Ink else Body,
42 + color = if (present) {
43 + MaterialTheme.colorScheme.onSurface
44 + } else {
45 + MaterialTheme.colorScheme.outline
46 + },
47 + textAlign = TextAlign.Center,
43 48 modifier = Modifier
44 - .padding(vertical = 1.dp)
49 + // Cible tactile élargie : toute la largeur du rail est cliquable.
50 + .fillMaxWidth()
45 51 .then(
46 52 if (present) Modifier.clickable { onLetter(letter) } else Modifier,
47 - ),
53 + )
54 + .padding(vertical = 2.dp),
48 55 )
49 56 }
50 57 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+67 -39
@@ -1,7 +1,5 @@
1 1 package fr.ebii.card2vcf.ui.carnet
2 2
3 -import androidx.compose.foundation.background
4 -import androidx.compose.foundation.clickable
5 3 import androidx.compose.foundation.BorderStroke
6 4 import androidx.compose.foundation.background
7 5 import androidx.compose.foundation.border
@@ -14,14 +12,19 @@ import androidx.compose.foundation.layout.Spacer
14 12 import androidx.compose.foundation.layout.fillMaxSize
15 13 import androidx.compose.foundation.layout.fillMaxWidth
16 14 import androidx.compose.foundation.layout.height
15 +import androidx.compose.foundation.layout.heightIn
17 16 import androidx.compose.foundation.layout.padding
18 17 import androidx.compose.foundation.lazy.LazyColumn
19 18 import androidx.compose.foundation.lazy.LazyListState
20 19 import androidx.compose.foundation.lazy.items
21 20 import androidx.compose.foundation.lazy.rememberLazyListState
21 +import androidx.compose.foundation.layout.size
22 +import androidx.compose.foundation.shape.CircleShape
22 23 import androidx.compose.foundation.shape.RoundedCornerShape
23 24 import androidx.compose.material.icons.Icons
25 +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
24 26 import androidx.compose.material.icons.filled.Add
27 +import androidx.compose.material.icons.filled.ArrowDropDown
25 28 import androidx.compose.material.icons.filled.PhotoCamera
26 29 import androidx.compose.material.icons.filled.Settings
27 30 import androidx.compose.material.icons.filled.Sync
@@ -59,14 +62,15 @@ import fr.ebii.card2vcf.ui.nav.MainTabRow
59 62 import fr.ebii.card2vcf.ui.sync.SyncBanner
60 63 import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
61 64 import fr.ebii.card2vcf.ui.sync.SyncConflictDialog
65 +import androidx.compose.ui.draw.clip
62 66 import fr.ebii.card2vcf.ui.theme.Bordure
63 67 import fr.ebii.card2vcf.ui.theme.Fond
64 68 import fr.ebii.card2vcf.ui.theme.Ink
69 +import fr.ebii.card2vcf.ui.theme.Link
65 70 import fr.ebii.card2vcf.ui.theme.OnPrimary
66 71 import fr.ebii.card2vcf.ui.theme.TexteFaible
67 72 import kotlinx.coroutines.launch
68 73
69 -private val Square = RoundedCornerShape(0.dp)
70 74
71 75 @Composable
72 76 fun CarnetScreen(
@@ -83,6 +87,7 @@ fun CarnetScreen(
83 87 val sort by viewModel.sort.collectAsState()
84 88 val query by viewModel.query.collectAsState()
85 89 val sections by viewModel.sections.collectAsState()
90 + val badges by viewModel.badges.collectAsState()
86 91 val letterOffsets by viewModel.letterOffsets.collectAsState()
87 92 val listState = rememberLazyListState()
88 93 val scope = rememberCoroutineScope()
@@ -114,14 +119,12 @@ fun CarnetScreen(
114 119 Box {
115 120 SmallFloatingActionButton(
116 121 onClick = { fabMenuOpen = true },
117 - shape = Square,
118 - containerColor = Fond,
119 - contentColor = Ink,
120 - elevation = FloatingActionButtonDefaults.elevation(
121 - defaultElevation = 0.dp,
122 - pressedElevation = 0.dp,
122 + containerColor = MaterialTheme.colorScheme.surface,
123 + contentColor = MaterialTheme.colorScheme.onSurface,
124 + modifier = Modifier.border(
125 + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
126 + MaterialTheme.shapes.medium,
123 127 ),
124 - modifier = Modifier.border(BorderStroke(1.dp, Bordure), Square),
125 128 ) {
126 129 Icon(
127 130 Icons.Filled.Add,
@@ -131,7 +134,6 @@ fun CarnetScreen(
131 134 DropdownMenu(
132 135 expanded = fabMenuOpen,
133 136 onDismissRequest = { fabMenuOpen = false },
134 - shape = Square,
135 137 ) {
136 138 DropdownMenuItem(
137 139 text = { Text(stringResource(R.string.carnet_fab_manuel)) },
@@ -151,13 +153,8 @@ fun CarnetScreen(
151 153 }
152 154 FloatingActionButton(
153 155 onClick = onScan,
154 - shape = Square,
155 - containerColor = Ink,
156 - contentColor = OnPrimary,
157 - elevation = FloatingActionButtonDefaults.elevation(
158 - defaultElevation = 0.dp,
159 - pressedElevation = 0.dp,
160 - ),
156 + containerColor = MaterialTheme.colorScheme.primary,
157 + contentColor = MaterialTheme.colorScheme.onPrimary,
161 158 ) {
162 159 Icon(
163 160 Icons.Filled.PhotoCamera,
@@ -193,13 +190,17 @@ fun CarnetScreen(
193 190 }
194 191 }
195 192 Box {
196 - TextButton(onClick = { sortMenuOpen = true }, shape = Square) {
197 - Text(sortLabel(sort), color = Ink)
193 + TextButton(onClick = { sortMenuOpen = true }) {
194 + Text(sortLabel(sort), color = MaterialTheme.colorScheme.onSurface)
195 + Icon(
196 + Icons.Filled.ArrowDropDown,
197 + contentDescription = null,
198 + tint = MaterialTheme.colorScheme.onSurfaceVariant,
199 + )
198 200 }
199 201 DropdownMenu(
200 202 expanded = sortMenuOpen,
201 203 onDismissRequest = { sortMenuOpen = false },
202 - shape = Square,
203 204 ) {
204 205 ContactSort.entries.forEach { option ->
205 206 DropdownMenuItem(
@@ -231,16 +232,15 @@ fun CarnetScreen(
231 232 onValueChange = viewModel::setQuery,
232 233 modifier = Modifier.fillMaxWidth(),
233 234 singleLine = true,
234 - shape = Square,
235 235 placeholder = {
236 236 Text(stringResource(R.string.carnet_recherche), color = TexteFaible)
237 237 },
238 238 colors = OutlinedTextFieldDefaults.colors(
239 - focusedBorderColor = Ink,
240 - unfocusedBorderColor = Bordure,
241 - focusedTextColor = Ink,
242 - unfocusedTextColor = Ink,
243 - cursorColor = Ink,
239 + focusedBorderColor = MaterialTheme.colorScheme.secondary,
240 + unfocusedBorderColor = MaterialTheme.colorScheme.outline,
241 + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
242 + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
243 + cursorColor = MaterialTheme.colorScheme.secondary,
244 244 ),
245 245 )
246 246
@@ -276,6 +276,7 @@ fun CarnetScreen(
276 276 Row(Modifier.weight(1f).fillMaxWidth()) {
277 277 ContactList(
278 278 sections = sections,
279 + badges = badges,
279 280 listState = listState,
280 281 showSectionHeaders = sort != ContactSort.CREATED_AT,
281 282 onOpenContact = { id -> onOpenContact(id, sort) },
@@ -301,6 +302,7 @@ fun CarnetScreen(
301 302 @Composable
302 303 private fun ContactList(
303 304 sections: List<Pair<Char, List<CrmContactEntity>>>,
305 + badges: Map<Long, ContactSyncBadge>,
304 306 listState: LazyListState,
305 307 onOpenContact: (Long) -> Unit,
306 308 modifier: Modifier = Modifier,
@@ -327,6 +329,7 @@ private fun ContactList(
327 329 items(contacts, key = { it.id }) { contact ->
328 330 ContactRow(
329 331 contact = contact,
332 + badge = badges[contact.id],
330 333 onClick = { onOpenContact(contact.id) },
331 334 )
332 335 }
@@ -337,29 +340,54 @@ private fun ContactList(
337 340 @Composable
338 341 private fun ContactRow(
339 342 contact: CrmContactEntity,
343 + badge: ContactSyncBadge?,
340 344 onClick: () -> Unit,
341 345 ) {
342 - Column(
346 + Box(
343 347 Modifier
344 348 .fillMaxWidth()
349 + .heightIn(min = 48.dp)
345 350 .clickable(onClick = onClick),
346 351 ) {
347 - Column(Modifier.padding(vertical = 10.dp)) {
348 - Text(
349 - text = contactDisplayName(contact),
350 - style = MaterialTheme.typography.bodyLarge,
351 - color = Ink,
352 + Icon(
353 + Icons.AutoMirrored.Filled.KeyboardArrowRight,
354 + contentDescription = null,
355 + tint = MaterialTheme.colorScheme.outline,
356 + modifier = Modifier.align(Alignment.CenterEnd),
357 + )
358 + if (badge != null) {
359 + Box(
360 + Modifier
361 + .align(Alignment.TopStart)
362 + .padding(top = 12.dp)
363 + .size(8.dp)
364 + .clip(CircleShape)
365 + .background(
366 + when (badge) {
367 + ContactSyncBadge.PENDING -> Link
368 + ContactSyncBadge.ERROR -> MaterialTheme.colorScheme.error
369 + },
370 + ),
352 371 )
353 - val subtitle = contactSubtitle(contact)
354 - if (subtitle != null) {
372 + }
373 + Column(Modifier.fillMaxWidth().padding(start = if (badge != null) 16.dp else 0.dp)) {
374 + Column(Modifier.padding(vertical = 10.dp)) {
355 375 Text(
356 - text = subtitle,
357 - style = MaterialTheme.typography.bodyMedium,
358 - color = TexteFaible,
376 + text = contactDisplayName(contact),
377 + style = MaterialTheme.typography.bodyLarge,
378 + color = Ink,
359 379 )
380 + val subtitle = contactSubtitle(contact)
381 + if (subtitle != null) {
382 + Text(
383 + text = subtitle,
384 + style = MaterialTheme.typography.bodyMedium,
385 + color = TexteFaible,
386 + )
387 + }
360 388 }
389 + HorizontalDivider(color = Bordure, thickness = 1.dp)
361 390 }
362 - HorizontalDivider(color = Bordure, thickness = 1.dp)
363 391 }
364 392 }
365 393
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt
+9 -0
@@ -6,6 +6,7 @@ import fr.ebii.card2vcf.crm.AlphabetIndex
6 6 import fr.ebii.card2vcf.crm.ContactSort
7 7 import fr.ebii.card2vcf.data.ContactRepository
8 8 import fr.ebii.card2vcf.data.CrmContactEntity
9 +import fr.ebii.card2vcf.sync.SyncOpDao
9 10 import kotlinx.coroutines.ExperimentalCoroutinesApi
10 11 import kotlinx.coroutines.flow.MutableStateFlow
11 12 import kotlinx.coroutines.flow.SharingStarted
@@ -14,11 +15,13 @@ import kotlinx.coroutines.flow.asStateFlow
14 15 import kotlinx.coroutines.flow.combine
15 16 import kotlinx.coroutines.flow.flatMapLatest
16 17 import kotlinx.coroutines.flow.flow
18 +import kotlinx.coroutines.flow.flowOf
17 19 import kotlinx.coroutines.flow.stateIn
18 20
19 21 @OptIn(ExperimentalCoroutinesApi::class)
20 22 class CarnetViewModel(
21 23 private val repository: ContactRepository,
24 + syncOpDao: SyncOpDao? = null,
22 25 ) : ViewModel() {
23 26
24 27 private val _sort = MutableStateFlow(ContactSort.LAST_NAME)
@@ -40,6 +43,12 @@ class CarnetViewModel(
40 43 val contacts: StateFlow<List<CrmContactEntity>> = contactsFlow
41 44 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
42 45
46 + /** Badge de synchro par id de contact (bleu = en attente, rouge = échec). */
47 + val badges: StateFlow<Map<Long, ContactSyncBadge>> =
48 + combine(contacts, syncOpDao?.observeAll() ?: flowOf(emptyList())) { list, ops ->
49 + ContactSyncBadges.compute(list, ops)
50 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
51 +
43 52 val sections: StateFlow<List<Pair<Char, List<CrmContactEntity>>>> =
44 53 combine(contacts, _sort) { list, sort ->
45 54 AlphabetIndex.group(list, sort).toList()
A android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/ContactSyncBadge.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.sync.SyncOpEntity
5 +
6 +/** État de synchro d'une fiche contact : en attente (bleu) ou en échec (rouge). */
7 +enum class ContactSyncBadge { PENDING, ERROR }
8 +
9 +object ContactSyncBadges {
10 + private val CONTACT_TYPES = setOf("contact", "contact_media")
11 +
12 + /** Associe chaque contact ayant une op de sync en file à son badge ; ERROR prioritaire. */
13 + fun compute(
14 + contacts: List<CrmContactEntity>,
15 + ops: List<SyncOpEntity>,
16 + ): Map<Long, ContactSyncBadge> {
17 + val contactOps = ops.filter { it.entityType in CONTACT_TYPES }
18 + if (contactOps.isEmpty()) return emptyMap()
19 + val result = HashMap<Long, ContactSyncBadge>()
20 + for (contact in contacts) {
21 + val matched = contactOps.filter {
22 + it.localId == contact.id ||
23 + (it.serverId != null && it.serverId == contact.serverId)
24 + }
25 + if (matched.isEmpty()) continue
26 + result[contact.id] = if (matched.any { it.lastError != null }) {
27 + ContactSyncBadge.ERROR
28 + } else {
29 + ContactSyncBadge.PENDING
30 + }
31 + }
32 + return result
33 + }
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/composants/Composants.kt
+173 -0
@@ -0,0 +1,173 @@
1 +package fr.ebii.card2vcf.ui.composants
2 +
3 +import androidx.compose.foundation.layout.Box
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.Row
6 +import androidx.compose.foundation.layout.fillMaxSize
7 +import androidx.compose.foundation.layout.fillMaxWidth
8 +import androidx.compose.foundation.layout.padding
9 +import androidx.compose.foundation.text.KeyboardOptions
10 +import androidx.compose.material.icons.Icons
11 +import androidx.compose.material.icons.automirrored.filled.ArrowBack
12 +import androidx.compose.material.icons.filled.ArrowDropDown
13 +import androidx.compose.material3.CircularProgressIndicator
14 +import androidx.compose.material3.DropdownMenu
15 +import androidx.compose.material3.DropdownMenuItem
16 +import androidx.compose.material3.Icon
17 +import androidx.compose.material3.IconButton
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.material3.OutlinedButton
20 +import androidx.compose.material3.OutlinedTextField
21 +import androidx.compose.material3.OutlinedTextFieldDefaults
22 +import androidx.compose.material3.Text
23 +import androidx.compose.runtime.Composable
24 +import androidx.compose.runtime.getValue
25 +import androidx.compose.runtime.mutableStateOf
26 +import androidx.compose.runtime.remember
27 +import androidx.compose.runtime.setValue
28 +import androidx.compose.ui.Alignment
29 +import androidx.compose.ui.Modifier
30 +import androidx.compose.ui.unit.dp
31 +import fr.ebii.card2vcf.data.CrmContactEntity
32 +
33 +/** Barre de titre commune : retour + titre + actions à droite. */
34 +@Composable
35 +fun AppTopBar(
36 + titre: String,
37 + onBack: (() -> Unit)? = null,
38 + modifier: Modifier = Modifier,
39 + actions: @Composable () -> Unit = {},
40 +) {
41 + Row(
42 + modifier
43 + .fillMaxWidth()
44 + .padding(top = 12.dp, bottom = 8.dp),
45 + verticalAlignment = Alignment.CenterVertically,
46 + ) {
47 + if (onBack != null) {
48 + IconButton(onClick = onBack) {
49 + Icon(
50 + Icons.AutoMirrored.Filled.ArrowBack,
51 + contentDescription = null,
52 + tint = MaterialTheme.colorScheme.onSurface,
53 + )
54 + }
55 + }
56 + Text(
57 + text = titre,
58 + style = MaterialTheme.typography.titleMedium,
59 + color = MaterialTheme.colorScheme.onSurface,
60 + modifier = Modifier.weight(1f),
61 + )
62 + actions()
63 + }
64 +}
65 +
66 +/**
67 + * Champ texte de la charte : label persistant, fond légèrement teinté pour délimiter
68 + * la zone de saisie, focus menthe 2 dp (signature `text-input-focused` du serveur).
69 + */
70 +@Composable
71 +fun ChampTexte(
72 + value: String,
73 + onValueChange: (String) -> Unit,
74 + label: String,
75 + modifier: Modifier = Modifier,
76 + singleLine: Boolean = true,
77 + minLines: Int = 1,
78 + isError: Boolean = false,
79 + supportingText: String? = null,
80 + keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
81 + visualTransformation: androidx.compose.ui.text.input.VisualTransformation =
82 + androidx.compose.ui.text.input.VisualTransformation.None,
83 +) {
84 + OutlinedTextField(
85 + value = value,
86 + onValueChange = onValueChange,
87 + label = { Text(label) },
88 + modifier = modifier.fillMaxWidth(),
89 + singleLine = singleLine,
90 + minLines = minLines,
91 + isError = isError,
92 + supportingText = supportingText?.let { { Text(it) } },
93 + keyboardOptions = keyboardOptions,
94 + visualTransformation = visualTransformation,
95 + colors = OutlinedTextFieldDefaults.colors(
96 + focusedBorderColor = MaterialTheme.colorScheme.secondary,
97 + unfocusedBorderColor = MaterialTheme.colorScheme.outline,
98 + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
99 + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
100 + focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
101 + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
102 + ),
103 + )
104 +}
105 +
106 +/** Sélecteur à menu : bordure + chevron — une vraie affordance de « select ». */
107 +@Composable
108 +fun SelecteurMenu(
109 + label: String,
110 + valeur: String,
111 + options: List<String>,
112 + onSelect: (Int) -> Unit,
113 + modifier: Modifier = Modifier,
114 +) {
115 + var ouvert by remember { mutableStateOf(false) }
116 + Box(modifier) {
117 + OutlinedButton(onClick = { ouvert = true }) {
118 + Text(if (label.isEmpty()) valeur else "$label : $valeur")
119 + Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
120 + }
121 + DropdownMenu(expanded = ouvert, onDismissRequest = { ouvert = false }) {
122 + options.forEachIndexed { index, option ->
123 + DropdownMenuItem(
124 + text = { Text(option) },
125 + onClick = {
126 + ouvert = false
127 + onSelect(index)
128 + },
129 + )
130 + }
131 + }
132 + }
133 +}
134 +
135 +/** État vide centré (liste sans contenu). */
136 +@Composable
137 +fun EtatVide(texte: String, modifier: Modifier = Modifier) {
138 + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
139 + Text(
140 + texte,
141 + color = MaterialTheme.colorScheme.onSurfaceVariant,
142 + style = MaterialTheme.typography.bodyLarge,
143 + )
144 + }
145 +}
146 +
147 +/** État de chargement centré. */
148 +@Composable
149 +fun EtatChargement(texte: String? = null, modifier: Modifier = Modifier) {
150 + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
151 + Column(horizontalAlignment = Alignment.CenterHorizontally) {
152 + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary)
153 + if (texte != null) {
154 + Text(
155 + texte,
156 + color = MaterialTheme.colorScheme.onSurfaceVariant,
157 + style = MaterialTheme.typography.bodyMedium,
158 + modifier = Modifier.padding(top = 12.dp),
159 + )
160 + }
161 + }
162 + }
163 +}
164 +
165 +/** Nom affiché d'un contact (consolidation des 4 copies locales). */
166 +fun nomAffiche(contact: CrmContactEntity): String {
167 + val complet = contact.fullName?.trim().orEmpty()
168 + if (complet.isNotEmpty()) return complet
169 + val compose = listOfNotNull(contact.firstName?.trim(), contact.lastName?.trim())
170 + .filter { it.isNotEmpty() }
171 + .joinToString(" ")
172 + return compose.ifEmpty { contact.company?.trim().orEmpty().ifEmpty { "Sans nom" } }
173 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditScreen.kt
+2 -10
@@ -57,7 +57,6 @@ import fr.ebii.card2vcf.ui.theme.Surface
57 57 import fr.ebii.card2vcf.ui.theme.TexteFaible
58 58 import java.io.File
59 59
60 -private val Square = RoundedCornerShape(0.dp)
61 60
62 61 @Composable
63 62 fun ContactEditScreen(
@@ -147,14 +146,14 @@ fun ContactEditScreen(
147 146 contentDescription = stringResource(R.string.fiche_photo_profil),
148 147 modifier = Modifier
149 148 .size(96.dp)
150 - .border(1.dp, Bordure, Square),
149 + .border(1.dp, Bordure, MaterialTheme.shapes.small),
151 150 contentScale = ContentScale.Crop,
152 151 )
153 152 } else {
154 153 Box(
155 154 Modifier
156 155 .size(96.dp)
157 - .border(1.dp, Bordure, Square)
156 + .border(1.dp, Bordure, MaterialTheme.shapes.small)
158 157 .background(Surface),
159 158 )
160 159 }
@@ -175,14 +174,12 @@ fun ContactEditScreen(
175 174 takePicture.launch(uri)
176 175 },
177 176 modifier = Modifier.weight(1f),
178 - shape = Square,
179 177 ) {
180 178 Text(stringResource(R.string.edit_prendre_photo))
181 179 }
182 180 OutlinedButton(
183 181 onClick = { pickImage.launch("image/*") },
184 182 modifier = Modifier.weight(1f),
185 - shape = Square,
186 183 ) {
187 184 Text(stringResource(R.string.edit_choisir_photo))
188 185 }
@@ -193,7 +190,6 @@ fun ContactEditScreen(
193 190 OutlinedButton(
194 191 onClick = { onRescan(viewModel.contactId) },
195 192 modifier = Modifier.fillMaxWidth(),
196 - shape = Square,
197 193 ) {
198 194 Text(stringResource(R.string.edit_rescanner))
199 195 }
@@ -204,8 +200,6 @@ fun ContactEditScreen(
204 200 modifier = Modifier
205 201 .fillMaxWidth()
206 202 .padding(horizontal = 18.dp, vertical = 12.dp),
207 - shape = Square,
208 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
209 203 ) {
210 204 Text(stringResource(R.string.edit_enregistrer))
211 205 }
@@ -262,8 +256,6 @@ fun ManualContactScreen(
262 256 modifier = Modifier
263 257 .fillMaxWidth()
264 258 .padding(horizontal = 18.dp, vertical = 12.dp),
265 - shape = Square,
266 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
267 259 ) {
268 260 Text(stringResource(R.string.edit_enregistrer))
269 261 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+31 -18
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Box
13 13 import androidx.compose.foundation.layout.Column
14 14 import androidx.compose.foundation.layout.Row
15 15 import androidx.compose.foundation.layout.Spacer
16 +import androidx.compose.foundation.layout.heightIn
17 +import androidx.compose.foundation.layout.width
16 18 import androidx.compose.foundation.layout.fillMaxSize
17 19 import androidx.compose.foundation.layout.fillMaxWidth
18 20 import androidx.compose.foundation.layout.height
@@ -26,6 +28,8 @@ import androidx.compose.foundation.verticalScroll
26 28 import androidx.compose.material.icons.Icons
27 29 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
28 30 import androidx.compose.material.icons.automirrored.outlined.RotateLeft
31 +import androidx.compose.material.icons.filled.Call
32 +import androidx.compose.material.icons.filled.Email
29 33 import androidx.compose.material3.HorizontalDivider
30 34 import androidx.compose.material3.Icon
31 35 import androidx.compose.material3.IconButton
@@ -56,7 +60,6 @@ import fr.ebii.card2vcf.ui.theme.Link
56 60 import fr.ebii.card2vcf.ui.theme.Surface
57 61 import fr.ebii.card2vcf.ui.theme.TexteFaible
58 62
59 -private val Square = RoundedCornerShape(0.dp)
60 63
61 64 @Composable
62 65 fun ContactPagerScreen(
@@ -143,16 +146,14 @@ private fun FicheTopBar(
143 146 }
144 147 Spacer(Modifier.weight(1f))
145 148 if (onDuplicates != null && duplicateCount > 0) {
146 - TextButton(onClick = onDuplicates, shape = Square) {
147 - Text(
148 - stringResource(R.string.fiche_doublons, duplicateCount),
149 - color = Encre,
150 - )
149 + OutlinedButton(onClick = onDuplicates) {
150 + Text(stringResource(R.string.fiche_doublons, duplicateCount))
151 151 }
152 + Spacer(Modifier.width(8.dp))
152 153 }
153 154 if (onEdit != null) {
154 - TextButton(onClick = onEdit, shape = Square) {
155 - Text(stringResource(R.string.fiche_editer), color = Encre)
155 + OutlinedButton(onClick = onEdit) {
156 + Text(stringResource(R.string.fiche_editer))
156 157 }
157 158 }
158 159 }
@@ -201,7 +202,7 @@ private fun ContactFichePage(
201 202 contentDescription = stringResource(R.string.fiche_photo_profil),
202 203 modifier = Modifier
203 204 .size(96.dp)
204 - .border(1.dp, Bordure, Square),
205 + .border(1.dp, Bordure, MaterialTheme.shapes.small),
205 206 )
206 207
207 208 Text(
@@ -217,11 +218,13 @@ private fun ContactFichePage(
217 218 ClickableFieldBlock(
218 219 label = stringResource(R.string.scan_champ_tel),
219 220 values = phones,
221 + icone = Icons.Filled.Call,
220 222 onClick = ::openDial,
221 223 )
222 224 ClickableFieldBlock(
223 225 label = stringResource(R.string.scan_champ_email),
224 226 values = emails,
227 + icone = Icons.Filled.Email,
225 228 onClick = ::openEmail,
226 229 )
227 230 FieldBlock(stringResource(R.string.scan_champ_site), contact.website)
@@ -236,12 +239,11 @@ private fun ContactFichePage(
236 239 modifier = Modifier
237 240 .fillMaxWidth()
238 241 .height(220.dp)
239 - .border(1.dp, Bordure, Square),
242 + .border(1.dp, Bordure, MaterialTheme.shapes.small),
240 243 contentScale = ContentScale.Fit,
241 244 )
242 245 OutlinedButton(
243 246 onClick = onRotateCardLeft,
244 - shape = Square,
245 247 modifier = Modifier.fillMaxWidth(),
246 248 ) {
247 249 Icon(
@@ -274,21 +276,32 @@ private fun FieldBlock(label: String, value: String?) {
274 276 private fun ClickableFieldBlock(
275 277 label: String,
276 278 values: List<String>,
279 + icone: androidx.compose.ui.graphics.vector.ImageVector,
277 280 onClick: (String) -> Unit,
278 281 ) {
279 282 if (values.isEmpty()) return
280 283 Column(Modifier.fillMaxWidth()) {
281 284 Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
282 285 values.forEach { value ->
283 - Text(
284 - text = value,
285 - style = MaterialTheme.typography.bodyLarge,
286 - color = Link,
286 + Row(
287 287 modifier = Modifier
288 288 .fillMaxWidth()
289 - .clickable { onClick(value) }
290 - .padding(vertical = 4.dp),
291 - )
289 + .heightIn(min = 48.dp)
290 + .clickable { onClick(value) },
291 + verticalAlignment = Alignment.CenterVertically,
292 + ) {
293 + Icon(
294 + icone,
295 + contentDescription = null,
296 + tint = MaterialTheme.colorScheme.onSecondaryContainer,
297 + )
298 + Spacer(Modifier.width(8.dp))
299 + Text(
300 + text = value,
301 + style = MaterialTheme.typography.bodyLarge,
302 + color = MaterialTheme.colorScheme.onSecondaryContainer,
303 + )
304 + }
292 305 }
293 306 }
294 307 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewScreen.kt
+4 -10
@@ -41,6 +41,7 @@ import androidx.compose.runtime.remember
41 41 import androidx.compose.runtime.setValue
42 42 import androidx.compose.ui.Alignment
43 43 import androidx.compose.ui.Modifier
44 +import androidx.compose.ui.draw.clip
44 45 import androidx.compose.ui.graphics.asImageBitmap
45 46 import androidx.compose.ui.layout.ContentScale
46 47 import androidx.compose.ui.res.stringResource
@@ -60,7 +61,6 @@ import fr.ebii.card2vcf.ui.theme.Surface
60 61 import fr.ebii.card2vcf.ui.theme.TexteFaible
61 62 import java.io.File
62 63
63 -private val Square = RoundedCornerShape(0.dp)
64 64
65 65 @Composable
66 66 fun DuplicateReviewScreen(
@@ -180,7 +180,6 @@ fun DuplicateReviewScreen(
180 180 onClick = viewModel::deleteSelected,
181 181 enabled = hasSelection,
182 182 modifier = Modifier.fillMaxWidth(),
183 - shape = Square,
184 183 ) {
185 184 Text(stringResource(R.string.dup_supprimer))
186 185 }
@@ -188,15 +187,12 @@ fun DuplicateReviewScreen(
188 187 onClick = viewModel::markSelectedDifferent,
189 188 enabled = hasSelection,
190 189 modifier = Modifier.fillMaxWidth(),
191 - shape = Square,
192 190 ) {
193 191 Text(stringResource(R.string.dup_marquer_differents))
194 192 }
195 193 Button(
196 194 onClick = viewModel::startMerge,
197 195 modifier = Modifier.fillMaxWidth(),
198 - shape = Square,
199 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
200 196 ) {
201 197 Text(stringResource(R.string.dup_fusionner))
202 198 }
@@ -213,7 +209,8 @@ private fun ContactSummaryRow(
213 209 Row(
214 210 Modifier
215 211 .fillMaxWidth()
216 - .border(1.dp, Bordure, Square)
212 + .border(1.dp, Bordure, MaterialTheme.shapes.small)
213 + .clip(MaterialTheme.shapes.small)
217 214 .then(
218 215 if (onToggle != null) {
219 216 Modifier.clickable(onClick = onToggle)
@@ -354,15 +351,12 @@ private fun MergePanel(
354 351 OutlinedButton(
355 352 onClick = onCancel,
356 353 modifier = Modifier.fillMaxWidth(),
357 - shape = Square,
358 354 ) {
359 355 Text(stringResource(R.string.dup_annuler))
360 356 }
361 357 Button(
362 358 onClick = { onConfirm(choices) },
363 359 modifier = Modifier.fillMaxWidth(),
364 - shape = Square,
365 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
366 360 ) {
367 361 Text(stringResource(R.string.dup_confirmer_fusion))
368 362 }
@@ -436,7 +430,7 @@ private fun ImagePicker(
436 430 contentDescription = label,
437 431 modifier = Modifier
438 432 .size(48.dp)
439 - .border(1.dp, Bordure, Square),
433 + .border(1.dp, Bordure, MaterialTheme.shapes.small),
440 434 contentScale = ContentScale.Crop,
441 435 )
442 436 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/importvcf/VcfImportScreen.kt
+0 -9
@@ -36,7 +36,6 @@ import fr.ebii.card2vcf.ui.theme.OnPrimary
36 36 import fr.ebii.card2vcf.ui.theme.Surface
37 37 import fr.ebii.card2vcf.ui.theme.TexteFaible
38 38
39 -private val Square = RoundedCornerShape(0.dp)
40 39
41 40 @Composable
42 41 fun VcfImportScreen(
@@ -88,8 +87,6 @@ fun VcfImportScreen(
88 87 Button(
89 88 onClick = { launchPicker() },
90 89 modifier = Modifier.fillMaxWidth(),
91 - shape = Square,
92 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
93 90 ) {
94 91 Text(stringResource(R.string.import_choisir_fichier))
95 92 }
@@ -133,8 +130,6 @@ fun VcfImportScreen(
133 130 Button(
134 131 onClick = onBack,
135 132 modifier = Modifier.fillMaxWidth(),
136 - shape = Square,
137 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
138 133 ) {
139 134 Text(stringResource(R.string.import_retour_carnet))
140 135 }
@@ -144,7 +139,6 @@ fun VcfImportScreen(
144 139 launchPicker()
145 140 },
146 141 modifier = Modifier.fillMaxWidth(),
147 - shape = Square,
148 142 ) {
149 143 Text(stringResource(R.string.import_autre_fichier))
150 144 }
@@ -163,15 +157,12 @@ fun VcfImportScreen(
163 157 launchPicker()
164 158 },
165 159 modifier = Modifier.fillMaxWidth(),
166 - shape = Square,
167 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
168 160 ) {
169 161 Text(stringResource(R.string.import_reessayer))
170 162 }
171 163 OutlinedButton(
172 164 onClick = onBack,
173 165 modifier = Modifier.fillMaxWidth(),
174 - shape = Square,
175 166 ) {
176 167 Text(stringResource(R.string.scan_retour))
177 168 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/kanban/KanbanBoard.kt
+18 -19
@@ -16,8 +16,11 @@ import androidx.compose.foundation.lazy.itemsIndexed
16 16 import androidx.compose.foundation.rememberScrollState
17 17 import androidx.compose.foundation.shape.RoundedCornerShape
18 18 import androidx.compose.foundation.verticalScroll
19 +import androidx.compose.material.icons.Icons
20 +import androidx.compose.material.icons.filled.ArrowDropDown
19 21 import androidx.compose.material3.AlertDialog
20 22 import androidx.compose.material3.DropdownMenu
23 +import androidx.compose.material3.Icon
21 24 import androidx.compose.material3.DropdownMenuItem
22 25 import androidx.compose.material3.MaterialTheme
23 26 import androidx.compose.material3.OutlinedTextField
@@ -30,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
30 33 import androidx.compose.runtime.remember
31 34 import androidx.compose.runtime.setValue
32 35 import androidx.compose.ui.Modifier
36 +import androidx.compose.ui.draw.clip
33 37 import androidx.compose.ui.res.stringResource
34 38 import androidx.compose.ui.unit.dp
35 39 import fr.ebii.card2vcf.R
@@ -41,7 +45,6 @@ import fr.ebii.card2vcf.ui.theme.Fond
41 45 import fr.ebii.card2vcf.ui.theme.Ink
42 46 import fr.ebii.card2vcf.ui.theme.TexteFaible
43 47
44 -private val Square = RoundedCornerShape(0.dp)
45 48 private val ColumnWidth = 220.dp
46 49 private val BoardHeight = 360.dp
47 50
@@ -80,7 +83,6 @@ fun KanbanBoard(
80 83 }
81 84 TextButton(
82 85 onClick = { showCreateDialog = true },
83 - shape = Square,
84 86 enabled = colonnes.isNotEmpty(),
85 87 ) {
86 88 Text(stringResource(R.string.kanban_nouvelle_tache), color = Ink)
@@ -134,8 +136,7 @@ fun KanbanBoard(
134 136 private fun FilterChipButton(label: String, selected: Boolean, onClick: () -> Unit) {
135 137 TextButton(
136 138 onClick = onClick,
137 - shape = Square,
138 - modifier = Modifier.border(BorderStroke(1.dp, if (selected) Ink else Bordure), Square),
139 + modifier = Modifier.border(BorderStroke(1.dp, if (selected) Ink else Bordure), MaterialTheme.shapes.small),
139 140 ) {
140 141 Text(label, color = if (selected) Ink else TexteFaible)
141 142 }
@@ -189,7 +190,8 @@ private fun TacheCard(
189 190 Modifier
190 191 .fillMaxWidth()
191 192 .padding(vertical = 4.dp)
192 - .border(BorderStroke(1.dp, Bordure), Square)
193 + .border(BorderStroke(1.dp, Bordure), MaterialTheme.shapes.small)
194 + .clip(MaterialTheme.shapes.small)
193 195 .clickable(onClick = onEdit)
194 196 .padding(10.dp),
195 197 ) {
@@ -205,10 +207,10 @@ private fun TacheCard(
205 207 horizontalArrangement = Arrangement.SpaceBetween,
206 208 ) {
207 209 Row {
208 - TextButton(onClick = onMoveLeft, enabled = canMoveLeft, shape = Square) { Text("←") }
209 - TextButton(onClick = onMoveRight, enabled = canMoveRight, shape = Square) { Text("→") }
210 + TextButton(onClick = onMoveLeft, enabled = canMoveLeft) { Text("←") }
211 + TextButton(onClick = onMoveRight, enabled = canMoveRight) { Text("→") }
210 212 }
211 - TextButton(onClick = onDelete, shape = Square) {
213 + TextButton(onClick = onDelete) {
212 214 Text(stringResource(R.string.kanban_supprimer), color = Ink)
213 215 }
214 216 }
@@ -226,16 +228,15 @@ private fun TacheDialog(
226 228 var assigneA by remember(tache) { mutableStateOf(tache?.assigneA) }
227 229 var assigneMenuOpen by remember { mutableStateOf(false) }
228 230 val fieldColors = OutlinedTextFieldDefaults.colors(
229 - focusedBorderColor = Ink,
230 - unfocusedBorderColor = Bordure,
231 - focusedTextColor = Ink,
232 - unfocusedTextColor = Ink,
233 - cursorColor = Ink,
231 + focusedBorderColor = MaterialTheme.colorScheme.secondary,
232 + unfocusedBorderColor = MaterialTheme.colorScheme.outline,
233 + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
234 + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
235 + cursorColor = MaterialTheme.colorScheme.secondary,
234 236 )
235 237
236 238 AlertDialog(
237 239 onDismissRequest = onDismiss,
238 - shape = Square,
239 240 containerColor = Fond,
240 241 title = {
241 242 Text(
@@ -250,18 +251,17 @@ private fun TacheDialog(
250 251 onValueChange = { titre = it },
251 252 modifier = Modifier.fillMaxWidth(),
252 253 singleLine = true,
253 - shape = Square,
254 254 placeholder = { Text(stringResource(R.string.kanban_champ_titre), color = TexteFaible) },
255 255 colors = fieldColors,
256 256 )
257 257 Box {
258 - TextButton(onClick = { assigneMenuOpen = true }, shape = Square) {
258 + TextButton(onClick = { assigneMenuOpen = true }) {
259 259 Text(assigneA ?: stringResource(R.string.kanban_non_assigne), color = Ink)
260 + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = TexteFaible)
260 261 }
261 262 DropdownMenu(
262 263 expanded = assigneMenuOpen,
263 264 onDismissRequest = { assigneMenuOpen = false },
264 - shape = Square,
265 265 ) {
266 266 DropdownMenuItem(
267 267 text = { Text(stringResource(R.string.kanban_non_assigne)) },
@@ -283,13 +283,12 @@ private fun TacheDialog(
283 283 onConfirm(titre, assigneA)
284 284 onDismiss()
285 285 },
286 - shape = Square,
287 286 ) {
288 287 Text(stringResource(R.string.kanban_valider), color = Ink)
289 288 }
290 289 },
291 290 dismissButton = {
292 - TextButton(onClick = onDismiss, shape = Square) {
291 + TextButton(onClick = onDismiss) {
293 292 Text(stringResource(R.string.dup_annuler), color = Ink)
294 293 }
295 294 },
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+1 -1
@@ -84,7 +84,7 @@ fun Card2vcfNavHost(
84 84 modifier: Modifier = Modifier,
85 85 navController: NavHostController = rememberNavController(),
86 86 ) {
87 - val carnetVm = remember(repository) { CarnetViewModel(repository) }
87 + val carnetVm = remember(repository) { CarnetViewModel(repository, database.syncOpDao()) }
88 88 val context = LocalContext.current
89 89 val scanVm = remember(repository) {
90 90 ScanCarteViewModel(
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/MainTabRow.kt
+34 -11
@@ -1,25 +1,26 @@
1 1 package fr.ebii.card2vcf.ui.nav
2 2
3 +import androidx.compose.foundation.background
3 4 import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Box
6 +import androidx.compose.foundation.layout.Column
4 7 import androidx.compose.foundation.layout.Row
5 8 import androidx.compose.foundation.layout.fillMaxWidth
6 -import androidx.compose.foundation.shape.RoundedCornerShape
9 +import androidx.compose.foundation.layout.height
10 +import androidx.compose.foundation.layout.width
7 11 import androidx.compose.material3.MaterialTheme
8 12 import androidx.compose.material3.Text
9 13 import androidx.compose.material3.TextButton
10 14 import androidx.compose.runtime.Composable
15 +import androidx.compose.ui.Alignment
11 16 import androidx.compose.ui.Modifier
12 17 import androidx.compose.ui.res.stringResource
13 18 import androidx.compose.ui.unit.dp
14 19 import fr.ebii.card2vcf.R
15 -import fr.ebii.card2vcf.ui.theme.Ink
16 -import fr.ebii.card2vcf.ui.theme.TexteFaible
17 -
18 -private val Square = RoundedCornerShape(0.dp)
19 20
20 21 enum class MainTab { CARNET, PROJETS }
21 22
22 -/** Onglets éditoriaux Carnet | Projets — pas de NavigationBar Material coloré. */
23 +/** Onglets éditoriaux Carnet | Projets — l'onglet actif porte un indicateur menthe. */
23 24 @Composable
24 25 fun MainTabRow(
25 26 current: MainTab,
@@ -29,11 +30,33 @@ fun MainTabRow(
29 30 Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
30 31 MainTab.entries.forEach { tab ->
31 32 val selected = tab == current
32 - TextButton(onClick = { onSelect(tab) }, shape = Square) {
33 - Text(
34 - text = stringResource(labelFor(tab)),
35 - color = if (selected) Ink else TexteFaible,
36 - style = if (selected) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium,
33 + Column(horizontalAlignment = Alignment.CenterHorizontally) {
34 + TextButton(onClick = { onSelect(tab) }) {
35 + Text(
36 + text = stringResource(labelFor(tab)),
37 + color = if (selected) {
38 + MaterialTheme.colorScheme.onSurface
39 + } else {
40 + MaterialTheme.colorScheme.onSurfaceVariant
41 + },
42 + style = if (selected) {
43 + MaterialTheme.typography.labelLarge
44 + } else {
45 + MaterialTheme.typography.labelMedium
46 + },
47 + )
48 + }
49 + Box(
50 + Modifier
51 + .width(32.dp)
52 + .height(2.dp)
53 + .background(
54 + if (selected) {
55 + MaterialTheme.colorScheme.secondary
56 + } else {
57 + MaterialTheme.colorScheme.surface
58 + },
59 + ),
37 60 )
38 61 }
39 62 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+10 -11
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
12 12 import androidx.compose.foundation.shape.RoundedCornerShape
13 13 import androidx.compose.material.icons.Icons
14 14 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
15 +import androidx.compose.material.icons.filled.ArrowDropDown
15 16 import androidx.compose.material3.DropdownMenu
16 17 import androidx.compose.material3.DropdownMenuItem
17 18 import androidx.compose.material3.HorizontalDivider
@@ -42,7 +43,6 @@ import fr.ebii.card2vcf.ui.theme.Ink
42 43 import fr.ebii.card2vcf.ui.theme.Surface
43 44 import fr.ebii.card2vcf.ui.theme.TexteFaible
44 45
45 -private val Square = RoundedCornerShape(0.dp)
46 46
47 47 @Composable
48 48 fun ProjetDetailScreen(
@@ -142,11 +142,11 @@ private fun CrSection(
142 142 var contactMenuOpen by remember { mutableStateOf(false) }
143 143 val selectedContact = contacts.find { it.serverId == selectedContactServerId }
144 144 val fieldColors = OutlinedTextFieldDefaults.colors(
145 - focusedBorderColor = Ink,
146 - unfocusedBorderColor = Bordure,
147 - focusedTextColor = Ink,
148 - unfocusedTextColor = Ink,
149 - cursorColor = Ink,
145 + focusedBorderColor = MaterialTheme.colorScheme.secondary,
146 + unfocusedBorderColor = MaterialTheme.colorScheme.outline,
147 + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
148 + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
149 + cursorColor = MaterialTheme.colorScheme.secondary,
150 150 )
151 151
152 152 Column {
@@ -154,13 +154,14 @@ private fun CrSection(
154 154 Spacer(Modifier.height(6.dp))
155 155
156 156 Box {
157 - TextButton(onClick = { contactMenuOpen = true }, shape = Square) {
157 + TextButton(onClick = { contactMenuOpen = true }) {
158 158 Text(
159 159 selectedContact?.let { contactDisplayName(it) } ?: stringResource(R.string.projet_cr_choisir_contact),
160 160 color = Ink,
161 161 )
162 + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = TexteFaible)
162 163 }
163 - DropdownMenu(expanded = contactMenuOpen, onDismissRequest = { contactMenuOpen = false }, shape = Square) {
164 + DropdownMenu(expanded = contactMenuOpen, onDismissRequest = { contactMenuOpen = false }) {
164 165 contacts.forEach { contact ->
165 166 DropdownMenuItem(
166 167 text = { Text(contactDisplayName(contact)) },
@@ -180,7 +181,6 @@ private fun CrSection(
180 181 onValueChange = onSujetChange,
181 182 modifier = Modifier.fillMaxWidth(),
182 183 singleLine = true,
183 - shape = Square,
184 184 placeholder = { Text(stringResource(R.string.projet_cr_sujet_placeholder), color = TexteFaible) },
185 185 colors = fieldColors,
186 186 )
@@ -189,12 +189,11 @@ private fun CrSection(
189 189 value = description,
190 190 onValueChange = onDescriptionChange,
191 191 modifier = Modifier.fillMaxWidth(),
192 - shape = Square,
193 192 placeholder = { Text(stringResource(R.string.projet_cr_description_placeholder), color = TexteFaible) },
194 193 colors = fieldColors,
195 194 )
196 195 Spacer(Modifier.height(8.dp))
197 - TextButton(onClick = onSubmit, shape = Square, enabled = sujet.isNotBlank()) {
196 + TextButton(onClick = onSubmit, enabled = sujet.isNotBlank()) {
198 197 Text(stringResource(R.string.projet_cr_ajouter), color = Ink)
199 198 }
200 199
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+114 -1
@@ -13,24 +13,37 @@ import androidx.compose.foundation.layout.padding
13 13 import androidx.compose.foundation.lazy.LazyColumn
14 14 import androidx.compose.foundation.lazy.items
15 15 import androidx.compose.material.icons.Icons
16 +import androidx.compose.material.icons.filled.Add
16 17 import androidx.compose.material.icons.filled.Settings
17 18 import androidx.compose.material.icons.filled.Sync
19 +import androidx.compose.material3.AlertDialog
20 +import androidx.compose.material3.DropdownMenu
21 +import androidx.compose.material3.DropdownMenuItem
22 +import androidx.compose.material3.FloatingActionButton
18 23 import androidx.compose.material3.HorizontalDivider
19 24 import androidx.compose.material3.Icon
20 25 import androidx.compose.material3.IconButton
21 26 import androidx.compose.material3.MaterialTheme
27 +import androidx.compose.material3.OutlinedButton
28 +import androidx.compose.material3.OutlinedTextField
22 29 import androidx.compose.material3.Scaffold
23 30 import androidx.compose.material3.Text
31 +import androidx.compose.material3.TextButton
24 32 import androidx.compose.runtime.Composable
25 33 import androidx.compose.runtime.LaunchedEffect
26 34 import androidx.compose.runtime.collectAsState
27 35 import androidx.compose.runtime.getValue
36 +import androidx.compose.runtime.mutableIntStateOf
37 +import androidx.compose.runtime.mutableStateOf
38 +import androidx.compose.runtime.remember
39 +import androidx.compose.runtime.setValue
28 40 import androidx.compose.ui.Alignment
29 41 import androidx.compose.ui.Modifier
30 42 import androidx.compose.ui.res.stringResource
31 43 import androidx.compose.ui.unit.dp
32 44 import fr.ebii.card2vcf.R
33 45 import fr.ebii.card2vcf.data.ProjetEntity
46 +import fr.ebii.card2vcf.data.WorkflowEntity
34 47 import fr.ebii.card2vcf.ui.nav.MainTab
35 48 import fr.ebii.card2vcf.ui.nav.MainTabRow
36 49 import fr.ebii.card2vcf.ui.sync.SyncBanner
@@ -51,6 +64,8 @@ fun ProjetsListScreen(
51 64 modifier: Modifier = Modifier,
52 65 ) {
53 66 val projets by viewModel.projets.collectAsState()
67 + val workflows by viewModel.workflows.collectAsState()
68 + var showCreateDialog by remember { mutableStateOf(false) }
54 69 val syncConfigured by syncViewModel.configured.collectAsState()
55 70 val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
56 71 val localCalendarAhead by syncViewModel.localCalendarAhead.collectAsState()
@@ -63,7 +78,15 @@ fun ProjetsListScreen(
63 78 syncViewModel.refreshStatus()
64 79 }
65 80
66 - Scaffold(modifier = modifier.fillMaxSize().background(Fond), containerColor = Fond) { padding ->
81 + Scaffold(
82 + modifier = modifier.fillMaxSize().background(Fond),
83 + containerColor = Fond,
84 + floatingActionButton = {
85 + FloatingActionButton(onClick = { showCreateDialog = true }) {
86 + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.projet_creer))
87 + }
88 + },
89 + ) { padding ->
67 90 Column(
68 91 Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp),
69 92 ) {
@@ -126,6 +149,89 @@ fun ProjetsListScreen(
126 149 }
127 150 }
128 151 }
152 +
153 + if (showCreateDialog) {
154 + CreateProjetDialog(
155 + workflows = workflows,
156 + onCreate = { nom, description, workflowServerId ->
157 + viewModel.createProjet(nom, description, workflowServerId)
158 + showCreateDialog = false
159 + },
160 + onDismiss = { showCreateDialog = false },
161 + )
162 + }
163 +}
164 +
165 +@Composable
166 +private fun CreateProjetDialog(
167 + workflows: List<WorkflowEntity>,
168 + onCreate: (nom: String, description: String, workflowServerId: String) -> Unit,
169 + onDismiss: () -> Unit,
170 +) {
171 + var nom by remember { mutableStateOf("") }
172 + var description by remember { mutableStateOf("") }
173 + var workflowIndex by remember { mutableIntStateOf(0) }
174 + var menuOpen by remember { mutableStateOf(false) }
175 + val selected = workflows.getOrNull(workflowIndex) ?: workflows.firstOrNull()
176 +
177 + AlertDialog(
178 + onDismissRequest = onDismiss,
179 + title = { Text(stringResource(R.string.projet_creer)) },
180 + text = {
181 + Column {
182 + OutlinedTextField(
183 + value = nom,
184 + onValueChange = { nom = it },
185 + label = { Text(stringResource(R.string.projet_nom_label)) },
186 + singleLine = true,
187 + )
188 + Spacer(Modifier.height(8.dp))
189 + OutlinedTextField(
190 + value = description,
191 + onValueChange = { description = it },
192 + label = { Text(stringResource(R.string.projet_description_label)) },
193 + )
194 + Spacer(Modifier.height(8.dp))
195 + if (workflows.isEmpty()) {
196 + Text(
197 + stringResource(R.string.projets_workflows_absents),
198 + color = TexteFaible,
199 + style = MaterialTheme.typography.bodyMedium,
200 + )
201 + } else {
202 + Box {
203 + OutlinedButton(onClick = { menuOpen = true }) {
204 + Text("${stringResource(R.string.projet_workflow_label)} : ${selected?.nom.orEmpty()}")
205 + }
206 + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
207 + workflows.forEachIndexed { index, workflow ->
208 + DropdownMenuItem(
209 + text = { Text(workflow.nom) },
210 + onClick = {
211 + workflowIndex = index
212 + menuOpen = false
213 + },
214 + )
215 + }
216 + }
217 + }
218 + }
219 + }
220 + },
221 + confirmButton = {
222 + TextButton(
223 + enabled = nom.isNotBlank() && selected != null,
224 + onClick = { selected?.let { onCreate(nom, description, it.serverId) } },
225 + ) {
226 + Text(stringResource(R.string.projet_creer_valider))
227 + }
228 + },
229 + dismissButton = {
230 + TextButton(onClick = onDismiss) {
231 + Text(stringResource(R.string.projet_creer_annuler))
232 + }
233 + },
234 + )
129 235 }
130 236
131 237 @Composable
@@ -144,6 +250,13 @@ private fun ProjetRow(projet: ProjetEntity, onClick: () -> Unit) {
144 250 color = TexteFaible,
145 251 )
146 252 }
253 + if (projet.serverId == null) {
254 + Text(
255 + text = stringResource(R.string.projet_en_attente_synchro),
256 + style = MaterialTheme.typography.bodyMedium,
257 + color = TexteFaible,
258 + )
259 + }
147 260 }
148 261 HorizontalDivider(color = Bordure, thickness = 1.dp)
149 262 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModel.kt
+41 -1
@@ -4,14 +4,54 @@ import androidx.lifecycle.ViewModel
4 4 import androidx.lifecycle.viewModelScope
5 5 import fr.ebii.card2vcf.data.CrmDatabase
6 6 import fr.ebii.card2vcf.data.ProjetEntity
7 +import fr.ebii.card2vcf.data.WorkflowEntity
8 +import fr.ebii.card2vcf.sync.CreateProjetRequest
9 +import fr.ebii.card2vcf.sync.SyncOpEntity
10 +import fr.ebii.card2vcf.sync.syncJson
7 11 import kotlinx.coroutines.flow.SharingStarted
8 12 import kotlinx.coroutines.flow.StateFlow
9 13 import kotlinx.coroutines.flow.stateIn
14 +import kotlinx.coroutines.launch
15 +import kotlinx.serialization.encodeToString
10 16
11 17 class ProjetsViewModel(
12 - database: CrmDatabase,
18 + private val database: CrmDatabase,
13 19 ) : ViewModel() {
14 20
15 21 val projets: StateFlow<List<ProjetEntity>> = database.projetDao().observeAll()
16 22 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
23 +
24 + val workflows: StateFlow<List<WorkflowEntity>> = database.workflowDao().observeAll()
25 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
26 +
27 + /** Crée le projet localement et enfile l'op de sync ; le push renseignera `serverId`. */
28 + fun createProjet(nom: String, description: String, workflowServerId: String) {
29 + viewModelScope.launch {
30 + val now = System.currentTimeMillis()
31 + val localId = database.projetDao().upsert(
32 + ProjetEntity(
33 + nom = nom.trim(),
34 + description = description.trim(),
35 + workflowServerId = workflowServerId,
36 + createdAt = now,
37 + updatedAt = now,
38 + ),
39 + )
40 + database.syncOpDao().insert(
41 + SyncOpEntity(
42 + entityType = "projet",
43 + op = "create",
44 + payloadJson = syncJson.encodeToString(
45 + CreateProjetRequest(
46 + nom = nom.trim(),
47 + description = description.trim(),
48 + workflowId = workflowServerId,
49 + ),
50 + ),
51 + localId = localId,
52 + createdAt = now,
53 + ),
54 + )
55 + }
56 + }
17 57 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+27 -51
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
14 14 import androidx.compose.foundation.layout.fillMaxWidth
15 15 import androidx.compose.foundation.layout.padding
16 16 import androidx.compose.foundation.shape.RoundedCornerShape
17 +import androidx.compose.foundation.text.KeyboardOptions
17 18 import androidx.compose.material.icons.Icons
18 19 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
19 20 import androidx.compose.material3.AlertDialog
@@ -40,11 +41,13 @@ import androidx.compose.runtime.setValue
40 41 import androidx.compose.ui.Alignment
41 42 import androidx.compose.ui.Modifier
42 43 import androidx.compose.ui.platform.LocalContext
44 +import androidx.compose.ui.text.input.KeyboardType
43 45 import androidx.compose.ui.res.stringResource
44 46 import androidx.compose.ui.text.input.PasswordVisualTransformation
45 47 import androidx.compose.ui.unit.dp
46 48 import androidx.core.content.ContextCompat
47 49 import fr.ebii.card2vcf.R
50 +import fr.ebii.card2vcf.ui.composants.ChampTexte
48 51 import fr.ebii.card2vcf.ui.theme.Bordure
49 52 import fr.ebii.card2vcf.ui.theme.Fond
50 53 import fr.ebii.card2vcf.ui.theme.Ink
@@ -52,7 +55,6 @@ import fr.ebii.card2vcf.ui.theme.OnPrimary
52 55 import fr.ebii.card2vcf.ui.theme.Surface
53 56 import fr.ebii.card2vcf.ui.theme.TexteFaible
54 57
55 -private val Square = RoundedCornerShape(0.dp)
56 58 private val CalendarPermissions = arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
57 59
58 60 private fun hasCalendarPermissions(context: Context): Boolean =
@@ -128,8 +130,9 @@ private fun LoggedInContent(
128 130 )
129 131 Text(state.baseUrl, color = TexteFaible)
130 132 Text(stringResource(R.string.settings_cle_configuree), color = TexteFaible)
131 - TextButton(onClick = viewModel::disconnect, shape = Square) {
132 - Text(stringResource(R.string.settings_deconnecter), color = Ink)
133 + TextButton(onClick = viewModel::disconnect) {
134 + // Action destructive : couleur d'erreur de la charte.
135 + Text(stringResource(R.string.settings_deconnecter), color = MaterialTheme.colorScheme.error)
133 136 }
134 137
135 138 HorizontalDivider(color = Bordure, thickness = 1.dp)
@@ -145,16 +148,15 @@ private fun LoggedInContent(
145 148 if (state.pendingRemoval != null) {
146 149 AlertDialog(
147 150 onDismissRequest = viewModel::dismissRemoveBinding,
148 - shape = Square,
149 151 title = { Text(stringResource(R.string.settings_calendrier_retirer_titre), color = Ink) },
150 152 text = { Text(stringResource(R.string.settings_calendrier_retirer_message, state.pendingRemoval.displayName), color = Ink) },
151 153 confirmButton = {
152 - TextButton(onClick = viewModel::confirmRemoveBinding, shape = Square) {
154 + TextButton(onClick = viewModel::confirmRemoveBinding) {
153 155 Text(stringResource(R.string.settings_calendrier_retirer_confirmer), color = Ink)
154 156 }
155 157 },
156 158 dismissButton = {
157 - TextButton(onClick = viewModel::dismissRemoveBinding, shape = Square) {
159 + TextButton(onClick = viewModel::dismissRemoveBinding) {
158 160 Text(stringResource(R.string.settings_calendrier_retirer_annuler), color = Ink)
159 161 }
160 162 },
@@ -179,7 +181,7 @@ private fun CalendarSection(
179 181
180 182 if (!hasCalendarPermission) {
181 183 Text(stringResource(R.string.settings_calendrier_permission_manquante), color = TexteFaible)
182 - OutlinedButton(onClick = onRequestCalendarPermission, shape = Square) {
184 + OutlinedButton(onClick = onRequestCalendarPermission) {
183 185 Text(stringResource(R.string.settings_calendrier_autoriser))
184 186 }
185 187 return
@@ -194,7 +196,7 @@ private fun CalendarSection(
194 196
195 197 when {
196 198 state.catalogueLoading -> Text(stringResource(R.string.settings_calendrier_chargement), color = TexteFaible)
197 - state.catalogueError != null -> Text(state.catalogueError, color = Ink)
199 + state.catalogueError != null -> Text(state.catalogueError, color = MaterialTheme.colorScheme.error)
198 200 else -> {
199 201 RessourceGroup(
200 202 titleRes = R.string.settings_calendrier_salles,
@@ -269,45 +271,28 @@ private fun LoggedOutContent(
269 271 state: SettingsUiState.LoggedOut,
270 272 viewModel: SettingsViewModel,
271 273 ) {
272 - val fieldColors = OutlinedTextFieldDefaults.colors(
273 - focusedBorderColor = Ink,
274 - unfocusedBorderColor = Bordure,
275 - focusedTextColor = Ink,
276 - unfocusedTextColor = Ink,
277 - cursorColor = Ink,
278 - )
279 -
280 274 Column(
281 275 Modifier.fillMaxSize().padding(18.dp),
282 276 verticalArrangement = Arrangement.spacedBy(12.dp),
283 277 ) {
284 - OutlinedTextField(
278 + // Labels persistants : le nom du champ reste visible une fois rempli.
279 + ChampTexte(
285 280 value = state.baseUrl,
286 281 onValueChange = viewModel::setBaseUrl,
287 - modifier = Modifier.fillMaxWidth(),
288 - singleLine = true,
289 - shape = Square,
290 - placeholder = { Text(stringResource(R.string.settings_url_placeholder), color = TexteFaible) },
291 - colors = fieldColors,
282 + label = stringResource(R.string.settings_url_placeholder),
283 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
292 284 )
293 - OutlinedTextField(
285 + ChampTexte(
294 286 value = state.userName,
295 287 onValueChange = viewModel::setUserName,
296 - modifier = Modifier.fillMaxWidth(),
297 - singleLine = true,
298 - shape = Square,
299 - placeholder = { Text(stringResource(R.string.settings_utilisateur_placeholder), color = TexteFaible) },
300 - colors = fieldColors,
288 + label = stringResource(R.string.settings_utilisateur_placeholder),
301 289 )
302 - OutlinedTextField(
290 + ChampTexte(
303 291 value = state.password,
304 292 onValueChange = viewModel::setPassword,
305 - modifier = Modifier.fillMaxWidth(),
306 - singleLine = true,
307 - shape = Square,
293 + label = stringResource(R.string.settings_mot_de_passe_placeholder),
308 294 visualTransformation = PasswordVisualTransformation(),
309 - placeholder = { Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible) },
310 - colors = fieldColors,
295 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
311 296 )
312 297 ToggleRow(
313 298 label = stringResource(R.string.settings_allow_cleartext),
@@ -321,14 +306,12 @@ private fun LoggedOutContent(
321 306 style = MaterialTheme.typography.bodySmall,
322 307 )
323 308 if (state.error != null) {
324 - Text(state.error, color = Ink)
309 + Text(state.error, color = MaterialTheme.colorScheme.error)
325 310 }
326 311 Button(
327 312 onClick = viewModel::obtainKey,
328 313 enabled = !state.loading,
329 314 modifier = Modifier.fillMaxWidth(),
330 - shape = Square,
331 - colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
332 315 ) {
333 316 Text(stringResource(R.string.settings_obtenir_cle))
334 317 }
@@ -337,34 +320,27 @@ private fun LoggedOutContent(
337 320 if (state.pendingAuth != null) {
338 321 AlertDialog(
339 322 onDismissRequest = viewModel::cancelRetype,
340 - shape = Square,
341 323 title = { Text(stringResource(R.string.settings_retype_titre), color = Ink) },
342 324 text = {
343 325 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
344 - OutlinedTextField(
326 + ChampTexte(
345 327 value = state.retypePassword,
346 328 onValueChange = viewModel::setRetypePassword,
347 - modifier = Modifier.fillMaxWidth(),
348 - singleLine = true,
349 - shape = Square,
329 + label = stringResource(R.string.settings_mot_de_passe_placeholder),
350 330 visualTransformation = PasswordVisualTransformation(),
351 - placeholder = {
352 - Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible)
353 - },
354 - colors = fieldColors,
331 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
332 + isError = state.retypeError != null,
333 + supportingText = state.retypeError,
355 334 )
356 - if (state.retypeError != null) {
357 - Text(state.retypeError, color = Ink)
358 - }
359 335 }
360 336 },
361 337 confirmButton = {
362 - TextButton(onClick = viewModel::confirmRetype, shape = Square) {
338 + TextButton(onClick = viewModel::confirmRetype) {
363 339 Text(stringResource(R.string.settings_retype_confirmer), color = Ink)
364 340 }
365 341 },
366 342 dismissButton = {
367 - TextButton(onClick = viewModel::cancelRetype, shape = Square) {
343 + TextButton(onClick = viewModel::cancelRetype) {
368 344 Text(stringResource(R.string.settings_retype_annuler), color = Ink)
369 345 }
370 346 },
M android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+3 -5
@@ -21,7 +21,6 @@ import fr.ebii.card2vcf.ui.theme.Bordure
21 21 import fr.ebii.card2vcf.ui.theme.Fond
22 22 import fr.ebii.card2vcf.ui.theme.Ink
23 23
24 -private val Square = RoundedCornerShape(0.dp)
25 24
26 25 /**
27 26 * Bandeaux hairline empilés : A) résumé de la dernière synchro ([summary], avec le message serveur
@@ -83,7 +82,7 @@ private fun SyncBannerRow(label: String, syncing: Boolean, onSyncClick: () -> Un
83 82 horizontalArrangement = Arrangement.SpaceBetween,
84 83 ) {
85 84 Text(label, color = Ink, modifier = Modifier.weight(1f))
86 - TextButton(onClick = onSyncClick, enabled = !syncing, shape = Square) {
85 + TextButton(onClick = onSyncClick, enabled = !syncing) {
87 86 Text(
88 87 if (syncing) stringResource(R.string.sync_en_cours) else stringResource(R.string.sync_bouton),
89 88 color = Ink,
@@ -103,16 +102,15 @@ fun SyncConflictDialog(
103 102 if (conflict == null) return
104 103 AlertDialog(
105 104 onDismissRequest = onDismiss,
106 - shape = Square,
107 105 title = { Text(stringResource(R.string.sync_conflit_titre), color = Ink) },
108 106 text = { Text(stringResource(R.string.sync_conflit_message), color = Ink) },
109 107 confirmButton = {
110 - TextButton(onClick = onConfirm, shape = Square) {
108 + TextButton(onClick = onConfirm) {
111 109 Text(stringResource(R.string.sync_conflit_oui), color = Ink)
112 110 }
113 111 },
114 112 dismissButton = {
115 - TextButton(onClick = onDismiss, shape = Square) {
113 + TextButton(onClick = onDismiss) {
116 114 Text(stringResource(R.string.sync_conflit_non), color = Ink)
117 115 }
118 116 },
M android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Color.kt
+30 -8
@@ -2,20 +2,42 @@ package fr.ebii.card2vcf.ui.theme
2 2
3 3 import androidx.compose.ui.graphics.Color
4 4
5 -// Palette Card2vcf — design.md (éditorial N&B, accent link réservé au texte inline).
5 +// Palette PicLead — charte « ailiance brain » du serveur (Server/tailwind.config.js).
6 +// Les écrans lisent MaterialTheme.colorScheme.* ; ces tokens ne servent qu'au thème
7 +// (exception documentée : Link, réservé à la pastille de synchro « en attente »).
6 8
7 -val Ink = Color(0xFF000000)
8 -val InkSoft = Color(0xFF1A1A1A)
9 -val Body = Color(0xFF757575)
9 +val Ink = Color(0xFF0F1115)
10 +val Charcoal = Color(0xFF1F2937)
11 +val Slate = Color(0xFF475467)
12 +val Steel = Color(0xFF667085)
13 +val Stone = Color(0xFF98A2B3)
14 +val Muted = Color(0xFFB0B7C0)
10 15 val Canvas = Color(0xFFFFFFFF)
11 -val CanvasSoft = Color(0xFFF5F5F5)
12 -val Hairline = Color(0xFFE0E0E0)
16 +val SurfaceTeinte = Color(0xFFF4F5F7)
17 +val SurfaceSoft = Color(0xFFFAFBFC)
18 +val Hairline = Color(0xFFE4E6EA)
19 +val HairlineSoft = Color(0xFFEEF0F3)
20 +
21 +val BrandGreen = Color(0xFF00D4A4)
22 +val BrandGreenDeep = Color(0xFF00B389)
23 +val BrandGreenSoft = Color(0xFFE6FAF4)
24 +val BrandError = Color(0xFFE5484D)
25 +
26 +/// Fond d'état erreur, dérivé de `brand-error/10` côté serveur.
27 +val BrandErrorSoft = Color(0xFFFDECEA)
28 +val BrandWarn = Color(0xFFE89642)
29 +
30 +/** Bleu réservé à la pastille de synchro « non synchronisé » (spéc. utilisateur). */
13 31 val Link = Color(0xFF057DBC)
32 +
14 33 val OnPrimary = Color(0xFFFFFFFF)
15 34
16 -// Alias pour écrans migrés (noms historiques Luciole → tokens design.md)
35 +// Alias historiques (définitions du thème uniquement — ne plus importer dans les écrans).
36 +val InkSoft = Charcoal
37 +val Body = Steel
38 +val CanvasSoft = SurfaceTeinte
17 39 val Encre = Ink
18 40 val Fond = Canvas
19 41 val Surface = Canvas
20 42 val Bordure = Hairline
21 -val TexteFaible = Body
43 +val TexteFaible = Steel
M android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Theme.kt
+34 -12
@@ -7,30 +7,52 @@ import androidx.compose.material3.lightColorScheme
7 7 import androidx.compose.runtime.Composable
8 8 import androidx.compose.ui.unit.dp
9 9
10 +// Thème « ailiance brain » : CTA noir (primaire), accent menthe (secondaire/tertiaire),
11 +// tous les slots M3 posés pour supprimer les défauts lavande (menus, dialogs, switch).
10 12 private val Card2vcfColors = lightColorScheme(
11 13 primary = Ink,
12 14 onPrimary = OnPrimary,
13 - primaryContainer = CanvasSoft,
15 + primaryContainer = SurfaceTeinte,
14 16 onPrimaryContainer = Ink,
15 - secondary = InkSoft,
16 - onSecondary = OnPrimary,
17 + secondary = BrandGreen,
18 + onSecondary = Ink,
19 + secondaryContainer = BrandGreenSoft,
20 + onSecondaryContainer = BrandGreenDeep,
21 + tertiary = BrandGreen,
22 + onTertiary = Ink,
23 + tertiaryContainer = BrandGreenSoft,
24 + onTertiaryContainer = BrandGreenDeep,
25 + error = BrandError,
26 + onError = Canvas,
27 + errorContainer = BrandErrorSoft,
28 + onErrorContainer = BrandError,
17 29 background = Canvas,
18 30 onBackground = Ink,
19 31 surface = Canvas,
20 32 onSurface = Ink,
21 - surfaceVariant = CanvasSoft,
22 - onSurfaceVariant = Body,
23 - outline = Hairline,
33 + surfaceVariant = SurfaceTeinte,
34 + onSurfaceVariant = Steel,
35 + surfaceTint = Canvas,
36 + outline = Stone,
24 37 outlineVariant = Hairline,
38 + inverseSurface = Ink,
39 + inverseOnSurface = Canvas,
40 + inversePrimary = BrandGreen,
41 + scrim = Ink,
42 + surfaceContainerLowest = Canvas,
43 + surfaceContainerLow = SurfaceSoft,
44 + surfaceContainer = SurfaceSoft,
45 + surfaceContainerHigh = SurfaceTeinte,
46 + surfaceContainerHighest = SurfaceTeinte,
25 47 )
26 48
27 -/** Coins carrés — design.md `{rounded.none}`. */
49 +/** Coins de la charte serveur : champs 8, cartes 12 ; les boutons M3 restent en pilule. */
28 50 private val Card2vcfShapes = Shapes(
29 - extraSmall = RoundedCornerShape(0.dp),
30 - small = RoundedCornerShape(0.dp),
31 - medium = RoundedCornerShape(0.dp),
32 - large = RoundedCornerShape(0.dp),
33 - extraLarge = RoundedCornerShape(0.dp),
51 + extraSmall = RoundedCornerShape(4.dp),
52 + small = RoundedCornerShape(8.dp),
53 + medium = RoundedCornerShape(12.dp),
54 + large = RoundedCornerShape(16.dp),
55 + extraLarge = RoundedCornerShape(28.dp),
34 56 )
35 57
36 58 @Composable
M android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Type.kt
+7 -0
@@ -59,6 +59,13 @@ val Card2vcfTypography = Typography(
59 59 lineHeight = 20.sp,
60 60 letterSpacing = 0.3.sp,
61 61 ),
62 + titleSmall = TextStyle(
63 + fontFamily = Manrope,
64 + fontWeight = FontWeight.Bold,
65 + fontSize = 14.sp,
66 + lineHeight = 18.sp,
67 + letterSpacing = 0.3.sp,
68 + ),
62 69 bodyLarge = TextStyle(
63 70 fontFamily = Lora,
64 71 fontWeight = FontWeight.Normal,
M android/app/src/main/res/values-en/strings.xml
+8 -0
@@ -128,6 +128,14 @@
128 128 <string name="onglet_projets">Projects</string>
129 129
130 130 <string name="projets_vide">No projects — sync to fetch them</string>
131 + <string name="projet_creer">Create a project</string>
132 + <string name="projet_nom_label">Project name</string>
133 + <string name="projet_description_label">Description</string>
134 + <string name="projet_workflow_label">Workflow</string>
135 + <string name="projet_creer_valider">Create</string>
136 + <string name="projet_creer_annuler">Cancel</string>
137 + <string name="projet_en_attente_synchro">Waiting for sync</string>
138 + <string name="projets_workflows_absents">Sync first to fetch workflows</string>
131 139 <string name="projet_membres">Members</string>
132 140 <string name="projet_cr_titre">Report</string>
133 141 <string name="projet_cr_choisir_contact">Choose a contact</string>
M android/app/src/main/res/values/strings.xml
+8 -0
@@ -128,6 +128,14 @@
128 128 <string name="onglet_projets">Projets</string>
129 129
130 130 <string name="projets_vide">Aucun projet — synchronisez pour les récupérer</string>
131 + <string name="projet_creer">Créer un projet</string>
132 + <string name="projet_nom_label">Nom du projet</string>
133 + <string name="projet_description_label">Description</string>
134 + <string name="projet_workflow_label">Workflow</string>
135 + <string name="projet_creer_valider">Créer</string>
136 + <string name="projet_creer_annuler">Annuler</string>
137 + <string name="projet_en_attente_synchro">En attente de synchro</string>
138 + <string name="projets_workflows_absents">Synchronisez d\'abord pour récupérer les workflows</string>
131 139 <string name="projet_membres">Membres</string>
132 140 <string name="projet_cr_titre">Compte-rendu</string>
133 141 <string name="projet_cr_choisir_contact">Choisir un contact</string>
M android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt
+52 -0
@@ -131,6 +131,58 @@ class ContactRepositoryTest {
131 131 }
132 132
133 133 @Test
134 + fun updateFromCardPreservesSyncFields() = runBlocking {
135 + val id = repo.insertFromCard(
136 + ContactCard(fullName = "Ada", company = "Acme"),
137 + null,
138 + null,
139 + )
140 + dao.update(
141 + dao.getById(id)!!.copy(
142 + serverId = "srv-1",
143 + entrepriseServerId = "ent-42",
144 + statut = "client",
145 + etape = "gagne",
146 + tags = listOf("vip"),
147 + ),
148 + )
149 + repo.updateFromCard(
150 + id,
151 + ContactCard(fullName = "Ada Lovelace", company = "Acme"),
152 + cardBitmap = null,
153 + profileBitmap = null,
154 + )
155 + val updated = dao.getById(id)!!
156 + assertEquals("Ada Lovelace", updated.fullName)
157 + assertEquals("srv-1", updated.serverId)
158 + assertEquals("ent-42", updated.entrepriseServerId)
159 + assertEquals("client", updated.statut)
160 + assertEquals("gagne", updated.etape)
161 + assertEquals(listOf("vip"), updated.tags)
162 + }
163 +
164 + @Test
165 + fun updateFromCardResetsEntrepriseLinkWhenCompanyChanges() = runBlocking {
166 + val id = repo.insertFromCard(
167 + ContactCard(fullName = "Ada", company = "Acme"),
168 + null,
169 + null,
170 + )
171 + dao.update(
172 + dao.getById(id)!!.copy(serverId = "srv-1", entrepriseServerId = "ent-42"),
173 + )
174 + repo.updateFromCard(
175 + id,
176 + ContactCard(fullName = "Ada", company = "Globex"),
177 + cardBitmap = null,
178 + profileBitmap = null,
179 + )
180 + val updated = dao.getById(id)!!
181 + assertEquals("srv-1", updated.serverId)
182 + assertNull(updated.entrepriseServerId)
183 + }
184 +
185 + @Test
134 186 fun mergeContactsKeepsOneAndDeletesOthers() = runBlocking {
135 187 val idA = repo.insertFromCard(
136 188 ContactCard(fullName = "A", emails = listOf("a@ex.com"), phones = listOf("111")),
M android/app/src/test/java/fr/ebii/card2vcf/sync/ContactSyncMapperTest.kt
+47 -0
@@ -4,6 +4,7 @@ import fr.ebii.card2vcf.data.CrmContactEntity
4 4 import kotlinx.serialization.decodeFromString
5 5 import org.junit.Assert.assertEquals
6 6 import org.junit.Assert.assertNull
7 +import org.junit.Assert.assertTrue
7 8 import org.junit.Test
8 9
9 10 class ContactSyncMapperTest {
@@ -40,6 +41,52 @@ class ContactSyncMapperTest {
40 41 }
41 42
42 43 @Test
44 + fun sendsEntrepriseNomWhenNoServerLink() {
45 + val entity = CrmContactEntity(
46 + fullName = "Ada",
47 + company = " Acme ",
48 + )
49 +
50 + val request = syncJson.decodeFromString<CreateContactRequest>(
51 + ContactSyncMapper.toCreatePayload(entity),
52 + )
53 +
54 + assertNull(request.entrepriseId)
55 + assertEquals("Acme", request.entrepriseNom)
56 + assertTrue(ContactSyncMapper.toCreatePayload(entity).contains("\"entreprise_nom\""))
57 + }
58 +
59 + @Test
60 + fun omitsEntrepriseNomWhenServerLinkExists() {
61 + val entity = CrmContactEntity(
62 + fullName = "Ada",
63 + company = "Acme",
64 + entrepriseServerId = "ent-42",
65 + )
66 +
67 + val request = syncJson.decodeFromString<CreateContactRequest>(
68 + ContactSyncMapper.toCreatePayload(entity),
69 + )
70 +
71 + assertEquals("ent-42", request.entrepriseId)
72 + assertNull(request.entrepriseNom)
73 + }
74 +
75 + @Test
76 + fun omitsEntrepriseNomWhenCompanyBlank() {
77 + val entity = CrmContactEntity(
78 + fullName = "Ada",
79 + company = " ",
80 + )
81 +
82 + val request = syncJson.decodeFromString<CreateContactRequest>(
83 + ContactSyncMapper.toCreatePayload(entity),
84 + )
85 +
86 + assertNull(request.entrepriseNom)
87 + }
88 +
89 + @Test
43 90 fun splitsFullNameWhenFirstAndLastMissing() {
44 91 val entity = CrmContactEntity(
45 92 fullName = "Jean Paul Sartre",
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+7 -1
@@ -17,6 +17,9 @@ class FakeAilianceApi : AilianceApi {
17 17 var createRdvResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"rdv-generated"}""")
18 18 val createRdvCalls = mutableListOf<String>()
19 19
20 + var createProjetResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"projet-generated"}""")
21 + val createProjetCalls = mutableListOf<String>()
22 +
20 23 var statusQueries = mutableListOf<String?>()
21 24 var pullQueries = mutableListOf<String?>()
22 25
@@ -43,7 +46,10 @@ class FakeAilianceApi : AilianceApi {
43 46 override fun createEntreprise(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
44 47 override fun updateEntreprise(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
45 48 override fun deleteEntreprise(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
46 - override fun createProjet(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
49 + override fun createProjet(jsonBody: String): AilianceApiClient.ApiResult<String> {
50 + createProjetCalls += jsonBody
51 + return createProjetResult
52 + }
47 53 override fun updateProjet(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
48 54 override fun deleteProjet(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
49 55 override fun createTache(projetId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+47 -0
@@ -5,6 +5,7 @@ import androidx.room.Room
5 5 import androidx.test.core.app.ApplicationProvider
6 6 import fr.ebii.card2vcf.data.CrmContactEntity
7 7 import fr.ebii.card2vcf.data.CrmDatabase
8 +import fr.ebii.card2vcf.data.ProjetEntity
8 9 import fr.ebii.card2vcf.data.RdvEntity
9 10 import kotlinx.coroutines.runBlocking
10 11 import org.junit.After
@@ -156,6 +157,26 @@ class SyncEngineTest {
156 157 }
157 158
158 159 @Test
160 + fun syncNow_pushesProjetCreateOpAndMapsServerId() = runBlocking {
161 + val localId = db.projetDao().upsert(ProjetEntity(nom = "Salon", workflowServerId = "wf-1"))
162 + db.syncOpDao().insert(
163 + SyncOpEntity(
164 + entityType = "projet",
165 + op = "create",
166 + payloadJson = """{"nom":"Salon","workflow_id":"wf-1"}""",
167 + localId = localId,
168 + createdAt = 1L,
169 + ),
170 + )
171 +
172 + engine.syncNow()
173 +
174 + assertEquals(1, api.createProjetCalls.size)
175 + assertEquals(0, db.syncOpDao().listAll().size)
176 + assertEquals("projet-generated", db.projetDao().getByLocalId(localId)?.serverId)
177 + }
178 +
179 + @Test
159 180 fun syncNow_pushesCardImageAfterContactCreate() = runBlocking {
160 181 val ctx = ApplicationProvider.getApplicationContext<Context>()
161 182 val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
@@ -295,6 +316,32 @@ class SyncEngineTest {
295 316 }
296 317
297 318 @Test
319 + fun syncNow_pushFailureMarksOpWithAttemptsAndLastError() = runBlocking {
320 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Hors ligne"))
321 + db.syncOpDao().insert(
322 + SyncOpEntity(
323 + entityType = "contact",
324 + op = "create",
325 + payloadJson = """{"fullName":"Hors ligne"}""",
326 + localId = localId,
327 + createdAt = 1L,
328 + ),
329 + )
330 + api.createContactResult = AilianceApiClient.ApiResult.Err(500, "Erreur interne.")
331 +
332 + engine.syncNow()
333 +
334 + val op = db.syncOpDao().listAll().single()
335 + assertEquals(1, op.attempts)
336 + assertEquals("Erreur interne.", op.lastError)
337 +
338 + // Le push suivant réussit : l'op est dépilée, le badge disparaît.
339 + api.createContactResult = AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""")
340 + engine.syncNow()
341 + assertEquals(0, db.syncOpDao().listAll().size)
342 + }
343 +
344 + @Test
298 345 fun syncNow_reportsPushedAndReceivedCounts() = runBlocking {
299 346 val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau"))
300 347 db.syncOpDao().insert(
A android/app/src/test/java/fr/ebii/card2vcf/ui/carnet/ContactSyncBadgesTest.kt
+64 -0
@@ -0,0 +1,64 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.sync.SyncOpEntity
5 +import org.junit.Assert.assertEquals
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Test
8 +
9 +class ContactSyncBadgesTest {
10 + private val contact = CrmContactEntity(id = 1L, fullName = "Ada", serverId = "srv-1")
11 +
12 + @Test
13 + fun pendingWhenCreateOpMatchesByLocalId() {
14 + val ops = listOf(SyncOpEntity(entityType = "contact", op = "create", localId = 1L))
15 + assertEquals(
16 + mapOf(1L to ContactSyncBadge.PENDING),
17 + ContactSyncBadges.compute(listOf(contact), ops),
18 + )
19 + }
20 +
21 + @Test
22 + fun pendingWhenUpdateOpMatchesByServerId() {
23 + val ops = listOf(SyncOpEntity(entityType = "contact", op = "update", serverId = "srv-1"))
24 + assertEquals(
25 + mapOf(1L to ContactSyncBadge.PENDING),
26 + ContactSyncBadges.compute(listOf(contact), ops),
27 + )
28 + }
29 +
30 + @Test
31 + fun errorWinsOverPending() {
32 + val ops = listOf(
33 + SyncOpEntity(id = 1, entityType = "contact", op = "create", localId = 1L),
34 + SyncOpEntity(id = 2, entityType = "contact", op = "update", serverId = "srv-1", lastError = "HTTP 500"),
35 + )
36 + assertEquals(
37 + mapOf(1L to ContactSyncBadge.ERROR),
38 + ContactSyncBadges.compute(listOf(contact), ops),
39 + )
40 + }
41 +
42 + @Test
43 + fun contactWithoutOpHasNoBadge() {
44 + val ops = listOf(SyncOpEntity(entityType = "contact", op = "create", localId = 99L))
45 + assertTrue(ContactSyncBadges.compute(listOf(contact), ops).isEmpty())
46 + }
47 +
48 + @Test
49 + fun nonContactOpsAreIgnored() {
50 + val ops = listOf(SyncOpEntity(entityType = "projet", op = "create", localId = 1L))
51 + assertTrue(ContactSyncBadges.compute(listOf(contact), ops).isEmpty())
52 + }
53 +
54 + @Test
55 + fun mediaOpsCountAsContactOps() {
56 + val ops = listOf(
57 + SyncOpEntity(entityType = "contact_media", op = "create", localId = 1L, serverId = "srv-1"),
58 + )
59 + assertEquals(
60 + mapOf(1L to ContactSyncBadge.PENDING),
61 + ContactSyncBadges.compute(listOf(contact), ops),
62 + )
63 + }
64 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModelTest.kt
+65 -0
@@ -0,0 +1,65 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.WorkflowEntity
8 +import fr.ebii.card2vcf.sync.CreateProjetRequest
9 +import fr.ebii.card2vcf.sync.syncJson
10 +import kotlinx.coroutines.Dispatchers
11 +import kotlinx.coroutines.ExperimentalCoroutinesApi
12 +import kotlinx.coroutines.runBlocking
13 +import kotlinx.coroutines.test.resetMain
14 +import kotlinx.coroutines.test.setMain
15 +import kotlinx.serialization.decodeFromString
16 +import org.junit.After
17 +import org.junit.Assert.assertEquals
18 +import org.junit.Before
19 +import org.junit.Test
20 +import org.junit.runner.RunWith
21 +import org.robolectric.RobolectricTestRunner
22 +import org.robolectric.annotation.Config
23 +
24 +@OptIn(ExperimentalCoroutinesApi::class)
25 +@RunWith(RobolectricTestRunner::class)
26 +@Config(sdk = [31])
27 +class ProjetsViewModelTest {
28 + private lateinit var db: CrmDatabase
29 +
30 + @Before
31 + fun setUp() {
32 + Dispatchers.setMain(Dispatchers.Unconfined)
33 + val ctx = ApplicationProvider.getApplicationContext<Context>()
34 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
35 + .allowMainThreadQueries()
36 + .build()
37 + }
38 +
39 + @After
40 + fun tearDown() {
41 + db.close()
42 + Dispatchers.resetMain()
43 + }
44 +
45 + @Test
46 + fun createProjetInsertsEntityAndSyncOp() = runBlocking {
47 + db.workflowDao().upsert(WorkflowEntity(serverId = "wf-1", nom = "Vente"))
48 + val viewModel = ProjetsViewModel(db)
49 +
50 + viewModel.createProjet(" Salon ", " Stand A ", "wf-1")
51 +
52 + val projet = db.projetDao().listAll().single()
53 + assertEquals("Salon", projet.nom)
54 + assertEquals("Stand A", projet.description)
55 + assertEquals("wf-1", projet.workflowServerId)
56 +
57 + val op = db.syncOpDao().listAll().single()
58 + assertEquals("projet", op.entityType)
59 + assertEquals("create", op.op)
60 + assertEquals(projet.localId, op.localId)
61 + val payload = syncJson.decodeFromString<CreateProjetRequest>(op.payloadJson)
62 + assertEquals("Salon", payload.nom)
63 + assertEquals("wf-1", payload.workflowId)
64 + }
65 +}
M design.md
+257 -1
@@ -1,3 +1,259 @@
1 +
2 +
3 +version: alpha
4 +
5 +#name: Wired-design-analysis
6 +
7 +description: An inspired interpretation of Wired's design language — a flagship technology-magazine brand whose surface is a strict editorial duet of stark black wordmark on white canvas, anchored by a tall narrow custom display serif for hero headlines, a humanist serif body face for long-form reading, and a clean sans face for metadata; layout reads like a printed magazine ported to the web with very little marketing chrome.
8 +
9 +## colors:
10 + primary: "#000000"
11 + on-primary: "#ffffff"
12 + ink: "#000000"
13 + ink-soft: "#1a1a1a"
14 + body: "#757575"
15 + hairline: "#e0e0e0"
16 + canvas: "#ffffff"
17 + canvas-soft: "#f5f5f5"
18 + link: "#057dbc"
19 +
20 +##typography:
21 + display-hero:
22 + fontFamily: WiredDisplay, "Times New Roman", Georgia, serif
23 + fontSize: 64px
24 + fontWeight: 400
25 + lineHeight: 59.52px
26 + letterSpacing: -0.5px
27 + display-lg:
28 + fontFamily: WiredDisplay, "Times New Roman", Georgia, serif
29 + fontSize: 48px
30 + fontWeight: 400
31 + lineHeight: 50.4px
32 + letterSpacing: -0.4px
33 + display-md:
34 + fontFamily: WiredDisplay, "Times New Roman", Georgia, serif
35 + fontSize: 32px
36 + fontWeight: 400
37 + lineHeight: 35.2px
38 + letterSpacing: -0.3px
39 + display-sm:
40 + fontFamily: WiredDisplay, "Times New Roman", Georgia, serif
41 + fontSize: 26px
42 + fontWeight: 400
43 + lineHeight: 28.08px
44 + display-xs:
45 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
46 + fontSize: 20px
47 + fontWeight: 700
48 + lineHeight: 24px
49 + letterSpacing: -0.28px
50 + body-serif-lg:
51 + fontFamily: BreveText, Georgia, "Times New Roman", serif
52 + fontSize: 19px
53 + fontWeight: 400
54 + lineHeight: 27.93px
55 + letterSpacing: 0.108px
56 + body-serif-md:
57 + fontFamily: BreveText, Georgia, "Times New Roman", serif
58 + fontSize: 16px
59 + fontWeight: 400
60 + lineHeight: 24px
61 + letterSpacing: 0.09px
62 + body-md:
63 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
64 + fontSize: 17px
65 + fontWeight: 400
66 + lineHeight: 20px
67 + body-md-strong:
68 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
69 + fontSize: 17px
70 + fontWeight: 700
71 + lineHeight: 22px
72 + letterSpacing: -0.144px
73 + body-sm:
74 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
75 + fontSize: 14px
76 + fontWeight: 400
77 + lineHeight: 18px
78 + letterSpacing: 0.4px
79 + body-sm-strong:
80 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
81 + fontSize: 14px
82 + fontWeight: 700
83 + lineHeight: 18px
84 + letterSpacing: 0.4px
85 + byline:
86 + fontFamily: BreveText, Georgia, "Times New Roman", serif
87 + fontSize: 12.73px
88 + fontWeight: 700
89 + lineHeight: 28px
90 + letterSpacing: 0.108px
91 + caption:
92 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
93 + fontSize: 12px
94 + fontWeight: 400
95 + lineHeight: 16px
96 + button-md:
97 + fontFamily: Apercu, "Helvetica Neue", Helvetica, Arial, sans-serif
98 + fontSize: 16px
99 + fontWeight: 700
100 + lineHeight: 20px
101 + letterSpacing: 0.3px
102 +
103 +rounded:
104 + none: 0px
105 + full: 9999px
106 +
107 +spacing:
108 + xxs: 2px
109 + xs: 4px
110 + sm: 8px
111 + md: 12px
112 + lg: 16px
113 + xl: 20px
114 + 2xl: 24px
115 + 3xl: 32px
116 + 4xl: 48px
117 +
118 +components:
119 + nav-bar:
120 + backgroundColor: "{colors.canvas}"
121 + textColor: "{colors.ink}"
122 + typography: "{typography.body-sm-strong}"
123 + padding: "{spacing.md} {spacing.xl}"
124 + nav-link:
125 + textColor: "{colors.ink}"
126 + typography: "{typography.body-sm-strong}"
127 + button-primary:
128 + backgroundColor: "{colors.primary}"
129 + textColor: "{colors.on-primary}"
130 + typography: "{typography.button-md}"
131 + rounded: "{rounded.none}"
132 + padding: "{spacing.md} {spacing.xl}"
133 + button-outline:
134 + backgroundColor: "{colors.canvas}"
135 + textColor: "{colors.ink}"
136 + borderColor: "{colors.ink}"
137 + typography: "{typography.button-md}"
138 + rounded: "{rounded.none}"
139 + padding: "{spacing.md} {spacing.xl}"
140 + button-icon-circular:
141 + backgroundColor: "{colors.canvas}"
142 + textColor: "{colors.ink}"
143 + rounded: "{rounded.full}"
144 + padding: "{spacing.sm}"
145 + text-input:
146 + backgroundColor: "{colors.canvas}"
147 + textColor: "{colors.ink}"
148 + borderColor: "{colors.ink}"
149 + typography: "{typography.body-md}"
150 + rounded: "{rounded.none}"
151 + padding: "{spacing.md} {spacing.lg}"
152 + story-card-large:
153 + backgroundColor: "{colors.canvas}"
154 + textColor: "{colors.ink}"
155 + typography: "{typography.display-md}"
156 + padding: "{spacing.lg}"
157 + story-card:
158 + backgroundColor: "{colors.canvas}"
159 + textColor: "{colors.ink}"
160 + typography: "{typography.display-xs}"
161 + padding: "{spacing.md}"
162 + story-row:
163 + backgroundColor: "{colors.canvas}"
164 + textColor: "{colors.ink}"
165 + borderColor: "{colors.hairline}"
166 + typography: "{typography.body-md-strong}"
167 + padding: "{spacing.lg} 0"
168 + category-eyebrow:
169 + textColor: "{colors.ink}"
170 + typography: "{typography.body-sm-strong}"
171 + byline-row:
172 + backgroundColor: "{colors.canvas}"
173 + textColor: "{colors.body}"
174 + typography: "{typography.byline}"
175 + hero-band:
176 + backgroundColor: "{colors.canvas}"
177 + textColor: "{colors.ink}"
178 + typography: "{typography.display-hero}"
179 + padding: "{spacing.4xl} {spacing.xl}"
180 + masthead-band:
181 + backgroundColor: "{colors.canvas}"
182 + textColor: "{colors.ink}"
183 + typography: "{typography.body-md-strong}"
184 + padding: "{spacing.md} {spacing.xl}"
185 + hairline-divider:
186 + borderColor: "{colors.hairline}"
187 + footer:
188 + backgroundColor: "{colors.primary}"
189 + textColor: "{colors.on-primary}"
190 + typography: "{typography.body-sm}"
191 + padding: "{spacing.4xl} {spacing.xl}"
192 +
193 + # ─── Examples (illustrative) — auto-derived; resolve any TO_FILL markers below ───
194 + ex-pricing-tier:
195 + description: "Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface."
196 + backgroundColor: "{colors.canvas-soft}"
197 + textColor: "{colors.ink}"
198 + borderColor: "{colors.hairline}"
199 + rounded: "{rounded.none}"
200 + padding: "{spacing.lg}"
201 + ex-pricing-tier-featured:
202 + description: "Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode)."
203 + backgroundColor: "{colors.ink}"
204 + textColor: "{colors.on-primary}"
205 + rounded: "{rounded.none}"
206 + padding: "{spacing.lg}"
207 + ex-product-selector:
208 + description: "What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery)."
209 + backgroundColor: "{colors.canvas-soft}"
210 + rounded: "{rounded.none}"
211 + padding: "{spacing.lg}"
212 + ex-cart-drawer:
213 + description: "Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart)."
214 + backgroundColor: "{colors.canvas}"
215 + rounded: "{rounded.none}"
216 + padding: "{spacing.lg}"
217 + item-divider: "{colors.hairline}"
218 + ex-app-shell-row:
219 + description: "Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator."
220 + backgroundColor: "{colors.canvas}"
221 + activeIndicator: "{colors.primary}"
222 + rounded: "{rounded.none}"
223 + padding: "{spacing.md} {spacing.lg}"
224 + ex-data-table-cell:
225 + description: "Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm."
226 + headerBackground: "{colors.canvas-soft}"
227 + headerTypography: "{typography.caption}"
228 + bodyTypography: "{typography.body-sm}"
229 + cellPadding: "{spacing.md} {spacing.lg}"
230 + rowBorder: "{colors.hairline}"
231 + ex-auth-form-card:
232 + description: "Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside."
233 + backgroundColor: "{colors.canvas-soft}"
234 + rounded: "{rounded.none}"
235 + padding: "{spacing.lg}"
236 + ex-modal-card:
237 + description: "Modal dialog surface — same chrome as feature-card with elevated shadow."
238 + backgroundColor: "{colors.canvas}"
239 + rounded: "{rounded.none}"
240 + padding: "{spacing.lg}"
241 + ex-empty-state-card:
242 + description: "Empty-state illustration frame."
243 + backgroundColor: "{colors.canvas-soft}"
244 + rounded: "{rounded.none}"
245 + padding: "{spacing.3xl}"
246 + captionTypography: "{typography.body-md}"
247 + ex-toast:
248 + description: "Toast notification surface — feature-card shape + medium shadow."
249 + backgroundColor: "{colors.canvas}"
250 + rounded: "{rounded.none}"
251 + padding: "{spacing.md} {spacing.lg}"
252 + typography: "{typography.body-sm}"
253 +
254 +---
255 +
256 +
1 257 ## Overview
2 258
3 259 Wired is the flagship technology-magazine brand under Condé Nast — and the web surface refuses to dress itself as a SaaS marketing site. The page is unmistakably an editorial product: a white canvas, a strict black wordmark in the brand's proprietary `WiredDisplay` (a tall, narrow, high-contrast serif used at 64 px), and stacked story cards that read as a printed magazine grid ported to the screen. There is no atmospheric gradient, no decorative chrome, no chromatic accent — the brand's only colour beyond the black-and-white duet is the small `{colors.link}` (`#057dbc`) used for inline body links inside long-form articles.
@@ -241,4 +497,4 @@ The brand uses no drop-shadows. Surface contrast and hairline borders carry all
241 497 - Don't round button corners. The brand never softens its rectangular geometry.
242 498 - Don't drop a soft drop-shadow on cards. Surface contrast and hairlines carry elevation.
243 499 - Don't substitute the proprietary serif faces with a generic sans for display. The serif voice is the brand.
244 -- Don't promote display weight beyond 400. The brand's elegance is in the typeface design, not bold weight.
500 +- Don't promote display weight beyond 400. The brand's elegance is in the typeface design, not bold weight.
500 < \ No newline at end of file