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 |
GitRust