carnet contact copie edition recherche swipe
EBO <eric.bouhana@softalys.com> committé le 2026-07-22 17:08
9c721e05afdfd385e2612b56c85d8fd35059200e
1 parent(s)
25 fichiers modifiés
+200
-120
M
android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt
+7
-3
@@ -2,6 +2,7 @@ package fr.ebii.card2vcf.data
| 2 | 2 | |
| 3 | 3 | import android.content.Context |
| 4 | 4 | import android.graphics.Bitmap |
| 5 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 5 | 6 | import java.io.File |
| 6 | 7 | import java.io.FileOutputStream |
| 7 | 8 |
@@ -19,12 +20,15 @@ class ContactImageStore(private val context: Context) {
| 19 | 20 | dir(contactId).deleteRecursively() |
| 20 | 21 | } |
| 21 | 22 | |
| 23 | + /** | |
| 24 | + * Copie en ré-encodant après correction EXIF, pour que le fichier stocké | |
| 25 | + * soit déjà « droit ». | |
| 26 | + */ | |
| 22 | 27 | fun copyImage(fromPath: String?, toContactId: Long, name: String): String? { |
| 23 | 28 | if (fromPath.isNullOrBlank()) return null |
| 24 | 29 | val src = File(fromPath) |
| 25 | 30 | if (!src.isFile) return null |
| 26 | - val dest = File(dir(toContactId), name) | |
| 27 | - src.copyTo(dest, overwrite = true) | |
| 28 | - return dest.absolutePath | |
| 31 | + val oriented = ImageOrientation.fromFile(src)?.bitmap ?: return null | |
| 32 | + return saveJpeg(toContactId, name, oriented) | |
| 29 | 33 | } |
| 30 | 34 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+19
-0
@@ -1,10 +1,13 @@
| 1 | 1 | package fr.ebii.card2vcf.data |
| 2 | 2 | |
| 3 | 3 | import android.graphics.Bitmap |
| 4 | +import android.graphics.Matrix | |
| 4 | 5 | import fr.ebii.card2vcf.contact.ContactCard |
| 5 | 6 | import fr.ebii.card2vcf.crm.ContactMerge |
| 6 | 7 | import fr.ebii.card2vcf.crm.ContactNormalizer |
| 7 | 8 | import fr.ebii.card2vcf.crm.MergeFieldChoices |
| 9 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 10 | +import java.io.File | |
| 8 | 11 | import kotlinx.coroutines.flow.Flow |
| 9 | 12 | |
| 10 | 13 | data class VcfImportResult( |
@@ -90,6 +93,22 @@ class ContactRepository(
| 90 | 93 | images.deleteAll(id) |
| 91 | 94 | } |
| 92 | 95 | |
| 96 | + /** Tourne la photo carte de [degrees] (ex. -90 = 90° à gauche) et réécrit card.jpg. */ | |
| 97 | + suspend fun rotateCardImage(id: Long, degrees: Int = -90) { | |
| 98 | + val existing = dao.getById(id) ?: return | |
| 99 | + val path = existing.cardImagePath?.takeIf { it.isNotBlank() } ?: return | |
| 100 | + val src = ImageOrientation.fromFile(File(path))?.bitmap ?: return | |
| 101 | + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } | |
| 102 | + val rotated = Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) | |
| 103 | + val newPath = images.saveJpeg(id, "card.jpg", rotated) | |
| 104 | + dao.update( | |
| 105 | + existing.copy( | |
| 106 | + cardImagePath = newPath, | |
| 107 | + updatedAt = System.currentTimeMillis(), | |
| 108 | + ), | |
| 109 | + ) | |
| 110 | + } | |
| 111 | + | |
| 93 | 112 | suspend fun markDifferent(a: Long, b: Long) { |
| 94 | 113 | val lo = minOf(a, b) |
| 95 | 114 | val hi = maxOf(a, b) |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftScreen.kt
+8
-1
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
| 10 | 10 | import androidx.compose.foundation.layout.padding |
| 11 | 11 | import androidx.compose.foundation.rememberScrollState |
| 12 | 12 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 13 | +import androidx.compose.foundation.text.selection.SelectionContainer | |
| 13 | 14 | import androidx.compose.foundation.verticalScroll |
| 14 | 15 | import androidx.compose.material3.Button |
| 15 | 16 | import androidx.compose.material3.ButtonDefaults |
@@ -61,7 +62,13 @@ fun ContactDraftScreen(
| 61 | 62 | Modifier.fillMaxWidth().background(Surface) |
| 62 | 63 | .border(1.dp, Bordure).padding(12.dp), |
| 63 | 64 | ) { |
| 64 | - Text(draft.rawOcrText, fontFamily = FontFamily.Monospace, color = TexteFaible) | |
| 65 | + SelectionContainer { | |
| 66 | + Text( | |
| 67 | + draft.rawOcrText, | |
| 68 | + fontFamily = FontFamily.Monospace, | |
| 69 | + color = TexteFaible, | |
| 70 | + ) | |
| 71 | + } | |
| 65 | 72 | } |
| 66 | 73 | } |
| 67 | 74 | Button( |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+57
-31
@@ -2,6 +2,11 @@ package fr.ebii.card2vcf.ui.carnet
| 2 | 2 | |
| 3 | 3 | import androidx.compose.foundation.background |
| 4 | 4 | import androidx.compose.foundation.clickable |
| 5 | +import androidx.compose.foundation.BorderStroke | |
| 6 | +import androidx.compose.foundation.background | |
| 7 | +import androidx.compose.foundation.border | |
| 8 | +import androidx.compose.foundation.clickable | |
| 9 | +import androidx.compose.foundation.layout.Arrangement | |
| 5 | 10 | import androidx.compose.foundation.layout.Box |
| 6 | 11 | import androidx.compose.foundation.layout.Column |
| 7 | 12 | import androidx.compose.foundation.layout.Row |
@@ -17,15 +22,18 @@ import androidx.compose.foundation.lazy.rememberLazyListState
| 17 | 22 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 18 | 23 | import androidx.compose.material.icons.Icons |
| 19 | 24 | import androidx.compose.material.icons.filled.Add |
| 25 | +import androidx.compose.material.icons.filled.PhotoCamera | |
| 20 | 26 | import androidx.compose.material3.DropdownMenu |
| 21 | 27 | import androidx.compose.material3.DropdownMenuItem |
| 22 | 28 | import androidx.compose.material3.FloatingActionButton |
| 29 | +import androidx.compose.material3.FloatingActionButtonDefaults | |
| 23 | 30 | import androidx.compose.material3.HorizontalDivider |
| 24 | 31 | import androidx.compose.material3.Icon |
| 25 | 32 | import androidx.compose.material3.MaterialTheme |
| 26 | 33 | import androidx.compose.material3.OutlinedTextField |
| 27 | 34 | import androidx.compose.material3.OutlinedTextFieldDefaults |
| 28 | 35 | import androidx.compose.material3.Scaffold |
| 36 | +import androidx.compose.material3.SmallFloatingActionButton | |
| 29 | 37 | import androidx.compose.material3.Text |
| 30 | 38 | import androidx.compose.material3.TextButton |
| 31 | 39 | import androidx.compose.runtime.Composable |
@@ -76,43 +84,61 @@ fun CarnetScreen(
| 76 | 84 | modifier = modifier.fillMaxSize().background(Fond), |
| 77 | 85 | containerColor = Fond, |
| 78 | 86 | floatingActionButton = { |
| 79 | - Box { | |
| 87 | + Column( | |
| 88 | + horizontalAlignment = Alignment.End, | |
| 89 | + verticalArrangement = Arrangement.spacedBy(12.dp), | |
| 90 | + ) { | |
| 91 | + Box { | |
| 92 | + SmallFloatingActionButton( | |
| 93 | + onClick = { fabMenuOpen = true }, | |
| 94 | + shape = Square, | |
| 95 | + containerColor = Fond, | |
| 96 | + contentColor = Ink, | |
| 97 | + elevation = FloatingActionButtonDefaults.elevation( | |
| 98 | + defaultElevation = 0.dp, | |
| 99 | + pressedElevation = 0.dp, | |
| 100 | + ), | |
| 101 | + modifier = Modifier.border(BorderStroke(1.dp, Bordure), Square), | |
| 102 | + ) { | |
| 103 | + Icon( | |
| 104 | + Icons.Filled.Add, | |
| 105 | + contentDescription = stringResource(R.string.carnet_fab_ajouter), | |
| 106 | + ) | |
| 107 | + } | |
| 108 | + DropdownMenu( | |
| 109 | + expanded = fabMenuOpen, | |
| 110 | + onDismissRequest = { fabMenuOpen = false }, | |
| 111 | + shape = Square, | |
| 112 | + ) { | |
| 113 | + DropdownMenuItem( | |
| 114 | + text = { Text(stringResource(R.string.carnet_fab_manuel)) }, | |
| 115 | + onClick = { | |
| 116 | + fabMenuOpen = false | |
| 117 | + onManual() | |
| 118 | + }, | |
| 119 | + ) | |
| 120 | + DropdownMenuItem( | |
| 121 | + text = { Text(stringResource(R.string.carnet_fab_import)) }, | |
| 122 | + onClick = { | |
| 123 | + fabMenuOpen = false | |
| 124 | + onImportVcf() | |
| 125 | + }, | |
| 126 | + ) | |
| 127 | + } | |
| 128 | + } | |
| 80 | 129 | FloatingActionButton( |
| 81 | - onClick = { fabMenuOpen = true }, | |
| 130 | + onClick = onScan, | |
| 82 | 131 | shape = Square, |
| 83 | 132 | containerColor = Ink, |
| 84 | 133 | contentColor = OnPrimary, |
| 134 | + elevation = FloatingActionButtonDefaults.elevation( | |
| 135 | + defaultElevation = 0.dp, | |
| 136 | + pressedElevation = 0.dp, | |
| 137 | + ), | |
| 85 | 138 | ) { |
| 86 | 139 | Icon( |
| 87 | - Icons.Filled.Add, | |
| 88 | - contentDescription = stringResource(R.string.carnet_fab_ajouter), | |
| 89 | - ) | |
| 90 | - } | |
| 91 | - DropdownMenu( | |
| 92 | - expanded = fabMenuOpen, | |
| 93 | - onDismissRequest = { fabMenuOpen = false }, | |
| 94 | - shape = Square, | |
| 95 | - ) { | |
| 96 | - DropdownMenuItem( | |
| 97 | - text = { Text(stringResource(R.string.carnet_fab_scanner)) }, | |
| 98 | - onClick = { | |
| 99 | - fabMenuOpen = false | |
| 100 | - onScan() | |
| 101 | - }, | |
| 102 | - ) | |
| 103 | - DropdownMenuItem( | |
| 104 | - text = { Text(stringResource(R.string.carnet_fab_manuel)) }, | |
| 105 | - onClick = { | |
| 106 | - fabMenuOpen = false | |
| 107 | - onManual() | |
| 108 | - }, | |
| 109 | - ) | |
| 110 | - DropdownMenuItem( | |
| 111 | - text = { Text(stringResource(R.string.carnet_fab_import)) }, | |
| 112 | - onClick = { | |
| 113 | - fabMenuOpen = false | |
| 114 | - onImportVcf() | |
| 115 | - }, | |
| 140 | + Icons.Filled.PhotoCamera, | |
| 141 | + contentDescription = stringResource(R.string.carnet_fab_scanner), | |
| 116 | 142 | ) |
| 117 | 143 | } |
| 118 | 144 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditScreen.kt
+3
-3
@@ -1,6 +1,5 @@
| 1 | 1 | package fr.ebii.card2vcf.ui.contact |
| 2 | 2 | |
| 3 | -import android.graphics.BitmapFactory | |
| 4 | 3 | import android.net.Uri |
| 5 | 4 | import androidx.activity.compose.rememberLauncherForActivityResult |
| 6 | 5 | import androidx.activity.result.contract.ActivityResultContracts |
@@ -47,6 +46,7 @@ import androidx.compose.ui.unit.dp
| 47 | 46 | import androidx.core.content.FileProvider |
| 48 | 47 | import fr.ebii.card2vcf.R |
| 49 | 48 | import fr.ebii.card2vcf.contact.ContactCard |
| 49 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 50 | 50 | import fr.ebii.card2vcf.ui.ContactDraftFields |
| 51 | 51 | import fr.ebii.card2vcf.ui.theme.Bordure |
| 52 | 52 | import fr.ebii.card2vcf.ui.theme.Encre |
@@ -85,7 +85,7 @@ fun ContactEditScreen(
| 85 | 85 | if (!success) return@rememberLauncherForActivityResult |
| 86 | 86 | val uri = pendingCaptureUri ?: return@rememberLauncherForActivityResult |
| 87 | 87 | context.contentResolver.openInputStream(uri)?.use { stream -> |
| 88 | - BitmapFactory.decodeStream(stream)?.let(viewModel::setProfileBitmap) | |
| 88 | + ImageOrientation.fromStream(stream)?.bitmap?.let(viewModel::setProfileBitmap) | |
| 89 | 89 | } |
| 90 | 90 | } |
| 91 | 91 |
@@ -94,7 +94,7 @@ fun ContactEditScreen(
| 94 | 94 | ) { uri -> |
| 95 | 95 | uri ?: return@rememberLauncherForActivityResult |
| 96 | 96 | context.contentResolver.openInputStream(uri)?.use { stream -> |
| 97 | - BitmapFactory.decodeStream(stream)?.let(viewModel::setProfileBitmap) | |
| 97 | + ImageOrientation.fromStream(stream)?.bitmap?.let(viewModel::setProfileBitmap) | |
| 98 | 98 | } |
| 99 | 99 | } |
| 100 | 100 |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditViewModel.kt
+3
-2
@@ -1,12 +1,13 @@
| 1 | 1 | package fr.ebii.card2vcf.ui.contact |
| 2 | 2 | |
| 3 | 3 | import android.graphics.Bitmap |
| 4 | -import android.graphics.BitmapFactory | |
| 5 | 4 | import androidx.lifecycle.ViewModel |
| 6 | 5 | import androidx.lifecycle.viewModelScope |
| 7 | 6 | import fr.ebii.card2vcf.contact.ContactCard |
| 8 | 7 | import fr.ebii.card2vcf.data.ContactRepository |
| 9 | 8 | import fr.ebii.card2vcf.data.toCard |
| 9 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 10 | +import java.io.File | |
| 10 | 11 | import kotlinx.coroutines.flow.MutableStateFlow |
| 11 | 12 | import kotlinx.coroutines.flow.StateFlow |
| 12 | 13 | import kotlinx.coroutines.flow.asStateFlow |
@@ -36,7 +37,7 @@ class ContactEditViewModel(
| 36 | 37 | val entity = repository.getById(contactId) ?: return@launch |
| 37 | 38 | _card.value = entity.toCard() |
| 38 | 39 | entity.profileImagePath?.takeIf { it.isNotBlank() }?.let { path -> |
| 39 | - _profilePreview.value = BitmapFactory.decodeFile(path) | |
| 40 | + _profilePreview.value = ImageOrientation.fromFile(File(path))?.bitmap | |
| 40 | 41 | } |
| 41 | 42 | _loaded.value = true |
| 42 | 43 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+72
-70
@@ -2,12 +2,12 @@ package fr.ebii.card2vcf.ui.contact
| 2 | 2 | |
| 3 | 3 | import android.content.ActivityNotFoundException |
| 4 | 4 | import android.content.Intent |
| 5 | -import android.graphics.BitmapFactory | |
| 6 | 5 | import android.net.Uri |
| 6 | +import java.io.File | |
| 7 | 7 | import androidx.compose.foundation.Image |
| 8 | 8 | import androidx.compose.foundation.background |
| 9 | 9 | import androidx.compose.foundation.border |
| 10 | -import androidx.compose.foundation.gestures.detectTapGestures | |
| 10 | +import androidx.compose.foundation.clickable | |
| 11 | 11 | import androidx.compose.foundation.layout.Arrangement |
| 12 | 12 | import androidx.compose.foundation.layout.Box |
| 13 | 13 | import androidx.compose.foundation.layout.Column |
@@ -25,39 +25,38 @@ import androidx.compose.foundation.shape.RoundedCornerShape
| 25 | 25 | import androidx.compose.foundation.verticalScroll |
| 26 | 26 | import androidx.compose.material.icons.Icons |
| 27 | 27 | import androidx.compose.material.icons.automirrored.outlined.ArrowBack |
| 28 | +import androidx.compose.material.icons.automirrored.outlined.RotateLeft | |
| 28 | 29 | import androidx.compose.material3.HorizontalDivider |
| 29 | 30 | import androidx.compose.material3.Icon |
| 30 | 31 | import androidx.compose.material3.IconButton |
| 31 | 32 | import androidx.compose.material3.MaterialTheme |
| 33 | +import androidx.compose.material3.OutlinedButton | |
| 32 | 34 | import androidx.compose.material3.Text |
| 33 | 35 | import androidx.compose.material3.TextButton |
| 34 | 36 | import androidx.compose.runtime.Composable |
| 35 | 37 | import androidx.compose.runtime.collectAsState |
| 36 | 38 | import androidx.compose.runtime.getValue |
| 37 | 39 | import androidx.compose.runtime.remember |
| 38 | -import androidx.compose.runtime.rememberCoroutineScope | |
| 39 | 40 | import androidx.compose.ui.Alignment |
| 40 | 41 | import androidx.compose.ui.Modifier |
| 41 | 42 | import androidx.compose.ui.graphics.ImageBitmap |
| 42 | 43 | import androidx.compose.ui.graphics.asImageBitmap |
| 43 | -import androidx.compose.ui.input.pointer.pointerInput | |
| 44 | 44 | import androidx.compose.ui.layout.ContentScale |
| 45 | 45 | import androidx.compose.ui.platform.LocalContext |
| 46 | 46 | import androidx.compose.ui.res.stringResource |
| 47 | 47 | import androidx.compose.ui.unit.dp |
| 48 | 48 | import fr.ebii.card2vcf.R |
| 49 | 49 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 50 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 50 | 51 | import fr.ebii.card2vcf.ui.theme.Bordure |
| 51 | 52 | import fr.ebii.card2vcf.ui.theme.Encre |
| 52 | 53 | import fr.ebii.card2vcf.ui.theme.Fond |
| 54 | +import fr.ebii.card2vcf.ui.theme.Ink | |
| 55 | +import fr.ebii.card2vcf.ui.theme.Link | |
| 53 | 56 | import fr.ebii.card2vcf.ui.theme.Surface |
| 54 | 57 | import fr.ebii.card2vcf.ui.theme.TexteFaible |
| 55 | -import kotlinx.coroutines.Job | |
| 56 | -import kotlinx.coroutines.delay | |
| 57 | -import kotlinx.coroutines.launch | |
| 58 | 58 | |
| 59 | 59 | private val Square = RoundedCornerShape(0.dp) |
| 60 | -private const val TapTimeoutMs = 400L | |
| 61 | 60 | |
| 62 | 61 | @Composable |
| 63 | 62 | fun ContactPagerScreen( |
@@ -114,7 +113,10 @@ fun ContactPagerScreen(
| 114 | 113 | state = pagerState, |
| 115 | 114 | modifier = Modifier.fillMaxSize(), |
| 116 | 115 | ) { page -> |
| 117 | - ContactFichePage(contact = contacts[page]) | |
| 116 | + ContactFichePage( | |
| 117 | + contact = contacts[page], | |
| 118 | + onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) }, | |
| 119 | + ) | |
| 118 | 120 | } |
| 119 | 121 | } |
| 120 | 122 | } |
@@ -159,25 +161,28 @@ private fun FicheTopBar(
| 159 | 161 | @Composable |
| 160 | 162 | private fun ContactFichePage( |
| 161 | 163 | contact: CrmContactEntity, |
| 164 | + onRotateCardLeft: () -> Unit, | |
| 162 | 165 | modifier: Modifier = Modifier, |
| 163 | 166 | ) { |
| 164 | 167 | val context = LocalContext.current |
| 165 | - val scope = rememberCoroutineScope() | |
| 166 | - val tracker = remember(contact.id) { TapGestureTracker() } | |
| 168 | + val phones = remember(contact.phones) { contact.phones.filter { it.isNotBlank() } } | |
| 169 | + val emails = remember(contact.emails) { contact.emails.filter { it.isNotBlank() } } | |
| 167 | 170 | |
| 168 | - fun openEmail() { | |
| 169 | - val email = contact.emails.firstOrNull()?.takeIf { it.isNotBlank() } ?: return | |
| 171 | + fun openEmail(email: String) { | |
| 170 | 172 | try { |
| 171 | - context.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email"))) | |
| 173 | + context.startActivity( | |
| 174 | + Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${Uri.encode(email)}")), | |
| 175 | + ) | |
| 172 | 176 | } catch (_: ActivityNotFoundException) { |
| 173 | 177 | // no-op |
| 174 | 178 | } |
| 175 | 179 | } |
| 176 | 180 | |
| 177 | - fun openDial() { | |
| 178 | - val phone = contact.phones.firstOrNull()?.takeIf { it.isNotBlank() } ?: return | |
| 181 | + fun openDial(phone: String) { | |
| 179 | 182 | try { |
| 180 | - context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone"))) | |
| 183 | + val tel = phone.filter { it.isDigit() || it == '+' } | |
| 184 | + if (tel.isEmpty()) return | |
| 185 | + context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$tel"))) | |
| 181 | 186 | } catch (_: ActivityNotFoundException) { |
| 182 | 187 | // no-op |
| 183 | 188 | } |
@@ -186,23 +191,13 @@ private fun ContactFichePage(
| 186 | 191 | Column( |
| 187 | 192 | modifier |
| 188 | 193 | .fillMaxSize() |
| 189 | - .pointerInput(contact.id, contact.emails, contact.phones) { | |
| 190 | - // onDoubleTap → mailto (différé 400 ms) ; 3ᵉ tap dans la fenêtre → dial. | |
| 191 | - detectTapGestures( | |
| 192 | - onDoubleTap = { | |
| 193 | - tracker.onDoubleTapArm(scope = scope, onDouble = { openEmail() }) | |
| 194 | - }, | |
| 195 | - onTap = { | |
| 196 | - tracker.onTap(onTriple = { openDial() }) | |
| 197 | - }, | |
| 198 | - ) | |
| 199 | - } | |
| 200 | 194 | .verticalScroll(rememberScrollState()) |
| 201 | 195 | .padding(horizontal = 18.dp, vertical = 12.dp), |
| 202 | 196 | verticalArrangement = Arrangement.spacedBy(12.dp), |
| 203 | 197 | ) { |
| 204 | 198 | PathImage( |
| 205 | 199 | path = contact.profileImagePath, |
| 200 | + revision = contact.updatedAt, | |
| 206 | 201 | contentDescription = stringResource(R.string.fiche_photo_profil), |
| 207 | 202 | modifier = Modifier |
| 208 | 203 | .size(96.dp) |
@@ -219,13 +214,15 @@ private fun ContactFichePage(
| 219 | 214 | FieldBlock(stringResource(R.string.scan_champ_nom_famille), contact.lastName) |
| 220 | 215 | FieldBlock(stringResource(R.string.scan_champ_societe), contact.company) |
| 221 | 216 | FieldBlock(stringResource(R.string.scan_champ_poste), contact.jobTitle) |
| 222 | - FieldBlock( | |
| 223 | - stringResource(R.string.scan_champ_tel), | |
| 224 | - contact.phones.filter { it.isNotBlank() }.joinToString("\n").ifBlank { null }, | |
| 217 | + ClickableFieldBlock( | |
| 218 | + label = stringResource(R.string.scan_champ_tel), | |
| 219 | + values = phones, | |
| 220 | + onClick = ::openDial, | |
| 225 | 221 | ) |
| 226 | - FieldBlock( | |
| 227 | - stringResource(R.string.scan_champ_email), | |
| 228 | - contact.emails.filter { it.isNotBlank() }.joinToString("\n").ifBlank { null }, | |
| 222 | + ClickableFieldBlock( | |
| 223 | + label = stringResource(R.string.scan_champ_email), | |
| 224 | + values = emails, | |
| 225 | + onClick = ::openEmail, | |
| 229 | 226 | ) |
| 230 | 227 | FieldBlock(stringResource(R.string.scan_champ_site), contact.website) |
| 231 | 228 | FieldBlock(stringResource(R.string.scan_champ_adresse), contact.address) |
@@ -234,13 +231,27 @@ private fun ContactFichePage(
| 234 | 231 | HorizontalDivider(color = Bordure, thickness = 1.dp) |
| 235 | 232 | PathImage( |
| 236 | 233 | path = contact.cardImagePath, |
| 234 | + revision = contact.updatedAt, | |
| 237 | 235 | contentDescription = stringResource(R.string.fiche_photo_carte), |
| 238 | 236 | modifier = Modifier |
| 239 | 237 | .fillMaxWidth() |
| 240 | - .height(200.dp) | |
| 238 | + .height(220.dp) | |
| 241 | 239 | .border(1.dp, Bordure, Square), |
| 242 | 240 | contentScale = ContentScale.Fit, |
| 243 | 241 | ) |
| 242 | + OutlinedButton( | |
| 243 | + onClick = onRotateCardLeft, | |
| 244 | + shape = Square, | |
| 245 | + modifier = Modifier.fillMaxWidth(), | |
| 246 | + ) { | |
| 247 | + Icon( | |
| 248 | + Icons.AutoMirrored.Outlined.RotateLeft, | |
| 249 | + contentDescription = null, | |
| 250 | + tint = Ink, | |
| 251 | + ) | |
| 252 | + Spacer(Modifier.size(8.dp)) | |
| 253 | + Text(stringResource(R.string.fiche_tourner_gauche), color = Ink) | |
| 254 | + } | |
| 244 | 255 | } |
| 245 | 256 | |
| 246 | 257 | if (!contact.notes.isNullOrBlank()) { |
@@ -250,39 +261,6 @@ private fun ContactFichePage(
| 250 | 261 | } |
| 251 | 262 | } |
| 252 | 263 | |
| 253 | -/** | |
| 254 | - * Compteur pour triple-tap (timeout [TapTimeoutMs]). | |
| 255 | - * Le double-tap mail reste sur [detectTapGestures] `onDoubleTap`. | |
| 256 | - * | |
| 257 | - * Avec `onDoubleTap` présent, Compose n'émet `onTap` que pour les taps isolés ; | |
| 258 | - * pour un vrai 1-2-3 rapide on compte aussi via [onDoubleTapSeen] : | |
| 259 | - * double → arme une fenêtre, 3ᵉ tap (`onTap` suivant) → dial et annule le mail. | |
| 260 | - */ | |
| 261 | -private class TapGestureTracker { | |
| 262 | - private var armedForTriple = false | |
| 263 | - private var job: Job? = null | |
| 264 | - | |
| 265 | - fun onDoubleTapArm(scope: kotlinx.coroutines.CoroutineScope, onDouble: () -> Unit) { | |
| 266 | - armedForTriple = true | |
| 267 | - job?.cancel() | |
| 268 | - job = scope.launch { | |
| 269 | - delay(TapTimeoutMs) | |
| 270 | - if (armedForTriple) { | |
| 271 | - armedForTriple = false | |
| 272 | - onDouble() | |
| 273 | - } | |
| 274 | - } | |
| 275 | - } | |
| 276 | - | |
| 277 | - fun onTap(onTriple: () -> Unit) { | |
| 278 | - if (armedForTriple) { | |
| 279 | - armedForTriple = false | |
| 280 | - job?.cancel() | |
| 281 | - onTriple() | |
| 282 | - } | |
| 283 | - } | |
| 284 | -} | |
| 285 | - | |
| 286 | 264 | @Composable |
| 287 | 265 | private fun FieldBlock(label: String, value: String?) { |
| 288 | 266 | if (value.isNullOrBlank()) return |
@@ -293,15 +271,39 @@ private fun FieldBlock(label: String, value: String?) {
| 293 | 271 | } |
| 294 | 272 | |
| 295 | 273 | @Composable |
| 274 | +private fun ClickableFieldBlock( | |
| 275 | + label: String, | |
| 276 | + values: List<String>, | |
| 277 | + onClick: (String) -> Unit, | |
| 278 | +) { | |
| 279 | + if (values.isEmpty()) return | |
| 280 | + Column(Modifier.fillMaxWidth()) { | |
| 281 | + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible) | |
| 282 | + values.forEach { value -> | |
| 283 | + Text( | |
| 284 | + text = value, | |
| 285 | + style = MaterialTheme.typography.bodyLarge, | |
| 286 | + color = Link, | |
| 287 | + modifier = Modifier | |
| 288 | + .fillMaxWidth() | |
| 289 | + .clickable { onClick(value) } | |
| 290 | + .padding(vertical = 4.dp), | |
| 291 | + ) | |
| 292 | + } | |
| 293 | + } | |
| 294 | +} | |
| 295 | + | |
| 296 | +@Composable | |
| 296 | 297 | private fun PathImage( |
| 297 | 298 | path: String?, |
| 298 | 299 | contentDescription: String, |
| 299 | 300 | modifier: Modifier = Modifier, |
| 300 | 301 | contentScale: ContentScale = ContentScale.Crop, |
| 302 | + revision: Long = 0L, | |
| 301 | 303 | ) { |
| 302 | - val bitmap: ImageBitmap? = remember(path) { | |
| 304 | + val bitmap: ImageBitmap? = remember(path, revision) { | |
| 303 | 305 | path?.takeIf { it.isNotBlank() }?.let { p -> |
| 304 | - BitmapFactory.decodeFile(p)?.asImageBitmap() | |
| 306 | + ImageOrientation.fromFile(File(p))?.bitmap?.asImageBitmap() | |
| 305 | 307 | } |
| 306 | 308 | } |
| 307 | 309 | if (bitmap != null) { |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+9
-1
@@ -13,10 +13,11 @@ import kotlinx.coroutines.flow.StateFlow
| 13 | 13 | import kotlinx.coroutines.flow.map |
| 14 | 14 | import kotlinx.coroutines.flow.mapLatest |
| 15 | 15 | import kotlinx.coroutines.flow.stateIn |
| 16 | +import kotlinx.coroutines.launch | |
| 16 | 17 | |
| 17 | 18 | @OptIn(ExperimentalCoroutinesApi::class) |
| 18 | 19 | class ContactPagerViewModel( |
| 19 | - repository: ContactRepository, | |
| 20 | + private val repository: ContactRepository, | |
| 20 | 21 | val initialContactId: Long, |
| 21 | 22 | val sort: ContactSort, |
| 22 | 23 | ) : ViewModel() { |
@@ -39,4 +40,11 @@ class ContactPagerViewModel(
| 39 | 40 | val idx = list.indexOfFirst { it.id == initialContactId } |
| 40 | 41 | return if (idx >= 0) idx else 0 |
| 41 | 42 | } |
| 43 | + | |
| 44 | + /** 90° à gauche (sens antihoraire). */ | |
| 45 | + fun rotateCardLeft(contactId: Long) { | |
| 46 | + viewModelScope.launch { | |
| 47 | + repository.rotateCardImage(contactId, degrees = -90) | |
| 48 | + } | |
| 49 | + } | |
| 42 | 50 | } |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewScreen.kt
+3
-2
@@ -1,6 +1,5 @@
| 1 | 1 | package fr.ebii.card2vcf.ui.contact |
| 2 | 2 | |
| 3 | -import android.graphics.BitmapFactory | |
| 4 | 3 | import androidx.compose.foundation.Image |
| 5 | 4 | import androidx.compose.foundation.background |
| 6 | 5 | import androidx.compose.foundation.border |
@@ -51,6 +50,7 @@ import fr.ebii.card2vcf.R
| 51 | 50 | import fr.ebii.card2vcf.crm.MergeFieldChoices |
| 52 | 51 | import fr.ebii.card2vcf.crm.NotesMode |
| 53 | 52 | import fr.ebii.card2vcf.data.CrmContactEntity |
| 53 | +import fr.ebii.card2vcf.scan.ImageOrientation | |
| 54 | 54 | import fr.ebii.card2vcf.ui.theme.Bordure |
| 55 | 55 | import fr.ebii.card2vcf.ui.theme.Encre |
| 56 | 56 | import fr.ebii.card2vcf.ui.theme.Fond |
@@ -58,6 +58,7 @@ import fr.ebii.card2vcf.ui.theme.Ink
| 58 | 58 | import fr.ebii.card2vcf.ui.theme.OnPrimary |
| 59 | 59 | import fr.ebii.card2vcf.ui.theme.Surface |
| 60 | 60 | import fr.ebii.card2vcf.ui.theme.TexteFaible |
| 61 | +import java.io.File | |
| 61 | 62 | |
| 62 | 63 | private val Square = RoundedCornerShape(0.dp) |
| 63 | 64 |
@@ -427,7 +428,7 @@ private fun ImagePicker(
| 427 | 428 | ) |
| 428 | 429 | val path = pathOf(c) |
| 429 | 430 | val bitmap = remember(path) { |
| 430 | - path?.let { BitmapFactory.decodeFile(it)?.asImageBitmap() } | |
| 431 | + path?.let { ImageOrientation.fromFile(File(it))?.bitmap?.asImageBitmap() } | |
| 431 | 432 | } |
| 432 | 433 | if (bitmap != null) { |
| 433 | 434 | Image( |
M
android/app/src/main/res/drawable/ic_launcher_foreground.png
+0
-0
A
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+5
-0
@@ -0,0 +1,5 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | + <background android:drawable="@color/ic_launcher_background"/> | |
| 4 | + <foreground android:drawable="@drawable/ic_launcher_foreground"/> | |
| 5 | +</adaptive-icon> |
A
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+5
-0
@@ -0,0 +1,5 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | + <background android:drawable="@color/ic_launcher_background"/> | |
| 4 | + <foreground android:drawable="@drawable/ic_launcher_foreground"/> | |
| 5 | +</adaptive-icon> |
M
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
+0
-0
M
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
+0
-0
M
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
+0
-0
M
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
+0
-0
M
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
+0
-0
M
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
+0
-0
M
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+0
-0
M
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
+0
-0
M
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+0
-0
M
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
+0
-0
M
android/app/src/main/res/values-en/strings.xml
+4
-3
@@ -33,9 +33,9 @@
| 33 | 33 | <string name="carnet_tri_prenom">First name</string> |
| 34 | 34 | <string name="carnet_tri_entreprise">Company</string> |
| 35 | 35 | <string name="carnet_tri_date">Date</string> |
| 36 | - <string name="carnet_fab_ajouter">Add contact</string> | |
| 37 | - <string name="carnet_fab_scanner">Scan</string> | |
| 38 | - <string name="carnet_fab_manuel">Manual</string> | |
| 36 | + <string name="carnet_fab_ajouter">Other add options</string> | |
| 37 | + <string name="carnet_fab_scanner">Scan a card</string> | |
| 38 | + <string name="carnet_fab_manuel">Manual entry</string> | |
| 39 | 39 | <string name="carnet_fab_import">Import VCF</string> |
| 40 | 40 | <string name="carnet_vide">No contacts — scan a card or import a VCF</string> |
| 41 | 41 |
@@ -43,6 +43,7 @@
| 43 | 43 | <string name="fiche_doublons">%d duplicate(s)</string> |
| 44 | 44 | <string name="fiche_photo_profil">Profile photo</string> |
| 45 | 45 | <string name="fiche_photo_carte">Card photo</string> |
| 46 | + <string name="fiche_tourner_gauche">Rotate 90° left</string> | |
| 46 | 47 | |
| 47 | 48 | <string name="edit_titre">Edit contact</string> |
| 48 | 49 | <string name="edit_enregistrer">Save</string> |
M
android/app/src/main/res/values/colors.xml
+1
-1
@@ -1,4 +1,4 @@
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | 2 | <resources> |
| 3 | - <color name="ic_launcher_background">#000000</color> | |
| 3 | + <color name="ic_launcher_background">#9aceec</color> | |
| 4 | 4 | </resources> |
M
android/app/src/main/res/values/strings.xml
+4
-3
@@ -33,9 +33,9 @@
| 33 | 33 | <string name="carnet_tri_prenom">Prénom</string> |
| 34 | 34 | <string name="carnet_tri_entreprise">Entreprise</string> |
| 35 | 35 | <string name="carnet_tri_date">Date</string> |
| 36 | - <string name="carnet_fab_ajouter">Ajouter un contact</string> | |
| 37 | - <string name="carnet_fab_scanner">Scanner</string> | |
| 38 | - <string name="carnet_fab_manuel">Manuel</string> | |
| 36 | + <string name="carnet_fab_ajouter">Autres ajouts</string> | |
| 37 | + <string name="carnet_fab_scanner">Scanner une carte</string> | |
| 38 | + <string name="carnet_fab_manuel">Saisie manuelle</string> | |
| 39 | 39 | <string name="carnet_fab_import">Import VCF</string> |
| 40 | 40 | <string name="carnet_vide">Aucun contact — scanner une carte ou importer un VCF</string> |
| 41 | 41 |
@@ -43,6 +43,7 @@
| 43 | 43 | <string name="fiche_doublons">%d doublon(s)</string> |
| 44 | 44 | <string name="fiche_photo_profil">Photo de profil</string> |
| 45 | 45 | <string name="fiche_photo_carte">Photo de la carte</string> |
| 46 | + <string name="fiche_tourner_gauche">Tourner 90° à gauche</string> | |
| 46 | 47 | |
| 47 | 48 | <string name="edit_titre">Modifier le contact</string> |
| 48 | 49 | <string name="edit_enregistrer">Enregistrer</string> |
GitRust