feat(ui): édition contact notes profil et rescan carte
EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:47
03008de33d35ce31304401559bee925a3dacd126
1 parent(s)
9 fichiers modifiés
+476
-11
M
android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftScreen.kt
+26
-5
@@ -43,7 +43,10 @@ fun ContactDraftScreen(
| 43 | 43 | onExportVcf: () -> Unit, |
| 44 | 44 | onRetry: () -> Unit, |
| 45 | 45 | modifier: Modifier = Modifier, |
| 46 | + /** Mode rescan : bouton primary « Mettre à jour ». */ | |
| 47 | + onUpdateContact: (() -> Unit)? = null, | |
| 46 | 48 | ) { |
| 49 | + val isUpdate = onUpdateContact != null | |
| 47 | 50 | Column( |
| 48 | 51 | modifier.fillMaxSize().background(Fond).padding(horizontal = 18.dp, vertical = 16.dp), |
| 49 | 52 | verticalArrangement = Arrangement.spacedBy(14.dp), |
@@ -63,22 +66,40 @@ fun ContactDraftScreen(
| 63 | 66 | Text(draft.rawOcrText, fontFamily = FontFamily.Monospace, color = TexteFaible) |
| 64 | 67 | } |
| 65 | 68 | } |
| 66 | - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 69 | + if (isUpdate) { | |
| 67 | 70 | Button( |
| 68 | - onClick = onCreateContact, | |
| 69 | - modifier = Modifier.weight(1f), | |
| 71 | + onClick = onUpdateContact, | |
| 72 | + modifier = Modifier.fillMaxWidth(), | |
| 70 | 73 | shape = Square, |
| 71 | 74 | colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), |
| 72 | 75 | ) { |
| 73 | - Text(stringResource(R.string.scan_creer_contact)) | |
| 76 | + Text(stringResource(R.string.edit_mettre_a_jour)) | |
| 74 | 77 | } |
| 75 | 78 | OutlinedButton( |
| 76 | 79 | onClick = onExportVcf, |
| 77 | - modifier = Modifier.weight(1f), | |
| 80 | + modifier = Modifier.fillMaxWidth(), | |
| 78 | 81 | shape = Square, |
| 79 | 82 | ) { |
| 80 | 83 | Text(stringResource(R.string.scan_exporter_vcf)) |
| 81 | 84 | } |
| 85 | + } else { | |
| 86 | + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 87 | + Button( | |
| 88 | + onClick = onCreateContact, | |
| 89 | + modifier = Modifier.weight(1f), | |
| 90 | + shape = Square, | |
| 91 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 92 | + ) { | |
| 93 | + Text(stringResource(R.string.scan_creer_contact)) | |
| 94 | + } | |
| 95 | + OutlinedButton( | |
| 96 | + onClick = onExportVcf, | |
| 97 | + modifier = Modifier.weight(1f), | |
| 98 | + shape = Square, | |
| 99 | + ) { | |
| 100 | + Text(stringResource(R.string.scan_exporter_vcf)) | |
| 101 | + } | |
| 102 | + } | |
| 82 | 103 | } |
| 83 | 104 | OutlinedButton( |
| 84 | 105 | onClick = onRetry, |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteScreen.kt
+8
-0
@@ -56,9 +56,16 @@ fun ScanCarteScreen(
| 56 | 56 | onBack: () -> Unit, |
| 57 | 57 | onCreateContact: (fr.ebii.card2vcf.contact.ContactCard) -> Unit, |
| 58 | 58 | onExportVcf: (fr.ebii.card2vcf.contact.ContactCard) -> Unit, |
| 59 | + onContactUpdated: (contactId: Long) -> Unit, | |
| 59 | 60 | modifier: Modifier = Modifier, |
| 60 | 61 | ) { |
| 61 | 62 | val state by viewModel.state.collectAsState() |
| 63 | + val saveDoneId by viewModel.saveDoneId.collectAsState() | |
| 64 | + LaunchedEffect(saveDoneId) { | |
| 65 | + val id = saveDoneId ?: return@LaunchedEffect | |
| 66 | + viewModel.consumeSaveDone() | |
| 67 | + onContactUpdated(id) | |
| 68 | + } | |
| 62 | 69 | when (val s = state) { |
| 63 | 70 | is ScanUiState.DraftReady -> ContactDraftScreen( |
| 64 | 71 | draft = s.draft, |
@@ -66,6 +73,7 @@ fun ScanCarteScreen(
| 66 | 73 | onCreateContact = { onCreateContact(s.draft.card) }, |
| 67 | 74 | onExportVcf = { onExportVcf(s.draft.card) }, |
| 68 | 75 | onRetry = viewModel::reset, |
| 76 | + onUpdateContact = if (viewModel.isEditing) viewModel::updateFromDraft else null, | |
| 69 | 77 | modifier = modifier, |
| 70 | 78 | ) |
| 71 | 79 | ScanUiState.Scanning -> ScanProgress(R.string.scan_en_cours) |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteViewModel.kt
+40
-1
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
| 6 | 6 | import androidx.lifecycle.viewModelScope |
| 7 | 7 | import fr.ebii.card2vcf.contact.ContactCard |
| 8 | 8 | import fr.ebii.card2vcf.contact.ContactDraftMerge |
| 9 | +import fr.ebii.card2vcf.data.ContactRepository | |
| 9 | 10 | import fr.ebii.card2vcf.ocr.OcrEngine |
| 10 | 11 | import fr.ebii.card2vcf.ocr.OcrException |
| 11 | 12 | import fr.ebii.card2vcf.scan.ScanEngine |
@@ -36,11 +37,21 @@ data class ContactDraftUi(
| 36 | 37 | class ScanCarteViewModel( |
| 37 | 38 | private val scanEngine: ScanEngine, |
| 38 | 39 | private val ocrEngine: OcrEngine, |
| 40 | + private val repository: ContactRepository, | |
| 39 | 41 | ) : ViewModel() { |
| 40 | 42 | |
| 43 | + /** Non-null when rescanning an existing contact (update path). */ | |
| 44 | + var editingContactId: Long? = null | |
| 45 | + | |
| 46 | + val isEditing: Boolean | |
| 47 | + get() = editingContactId != null | |
| 48 | + | |
| 41 | 49 | private val _state = MutableStateFlow<ScanUiState>(ScanUiState.Idle) |
| 42 | 50 | val state: StateFlow<ScanUiState> = _state.asStateFlow() |
| 43 | 51 | |
| 52 | + private val _saveDoneId = MutableStateFlow<Long?>(null) | |
| 53 | + val saveDoneId: StateFlow<Long?> = _saveDoneId.asStateFlow() | |
| 54 | + | |
| 44 | 55 | fun onCaptureReady() { |
| 45 | 56 | _state.value = ScanUiState.Capturing |
| 46 | 57 | } |
@@ -53,9 +64,16 @@ class ScanCarteViewModel(
| 53 | 64 | _state.value = ScanUiState.OcrRunning |
| 54 | 65 | val ocr = withContext(Dispatchers.Default) { ocrEngine.recognize(scanned.bitmap) } |
| 55 | 66 | _state.value = ScanUiState.Structuring |
| 56 | - val merged = withContext(Dispatchers.Default) { | |
| 67 | + var merged = withContext(Dispatchers.Default) { | |
| 57 | 68 | ContactDraftMerge.merge(null, ocr) |
| 58 | 69 | } |
| 70 | + val editId = editingContactId | |
| 71 | + if (editId != null) { | |
| 72 | + val existing = withContext(Dispatchers.IO) { repository.getById(editId) } | |
| 73 | + if (existing != null && !existing.notes.isNullOrBlank()) { | |
| 74 | + merged = merged.copy(note = existing.notes) | |
| 75 | + } | |
| 76 | + } | |
| 59 | 77 | _state.value = ScanUiState.DraftReady( |
| 60 | 78 | ContactDraftUi( |
| 61 | 79 | card = merged, |
@@ -83,6 +101,27 @@ class ScanCarteViewModel(
| 83 | 101 | } |
| 84 | 102 | } |
| 85 | 103 | |
| 104 | + /** Met à jour le contact existant (mode rescan) à partir du brouillon courant. */ | |
| 105 | + fun updateFromDraft() { | |
| 106 | + val editId = editingContactId ?: return | |
| 107 | + val draft = (_state.value as? ScanUiState.DraftReady)?.draft ?: return | |
| 108 | + viewModelScope.launch { | |
| 109 | + withContext(Dispatchers.IO) { | |
| 110 | + repository.updateFromCard( | |
| 111 | + id = editId, | |
| 112 | + card = draft.card, | |
| 113 | + cardBitmap = draft.preview, | |
| 114 | + profileBitmap = null, | |
| 115 | + ) | |
| 116 | + } | |
| 117 | + _saveDoneId.value = editId | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + fun consumeSaveDone() { | |
| 122 | + _saveDoneId.value = null | |
| 123 | + } | |
| 124 | + | |
| 86 | 125 | fun reset() { |
| 87 | 126 | _state.value = ScanUiState.Idle |
| 88 | 127 | } |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditScreen.kt
+271
-0
@@ -0,0 +1,271 @@
| 1 | +package fr.ebii.card2vcf.ui.contact | |
| 2 | + | |
| 3 | +import android.graphics.BitmapFactory | |
| 4 | +import android.net.Uri | |
| 5 | +import androidx.activity.compose.rememberLauncherForActivityResult | |
| 6 | +import androidx.activity.result.contract.ActivityResultContracts | |
| 7 | +import androidx.compose.foundation.Image | |
| 8 | +import androidx.compose.foundation.background | |
| 9 | +import androidx.compose.foundation.border | |
| 10 | +import androidx.compose.foundation.layout.Arrangement | |
| 11 | +import androidx.compose.foundation.layout.Box | |
| 12 | +import androidx.compose.foundation.layout.Column | |
| 13 | +import androidx.compose.foundation.layout.Row | |
| 14 | +import androidx.compose.foundation.layout.Spacer | |
| 15 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 16 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 17 | +import androidx.compose.foundation.layout.height | |
| 18 | +import androidx.compose.foundation.layout.padding | |
| 19 | +import androidx.compose.foundation.layout.size | |
| 20 | +import androidx.compose.foundation.rememberScrollState | |
| 21 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 22 | +import androidx.compose.foundation.verticalScroll | |
| 23 | +import androidx.compose.material.icons.Icons | |
| 24 | +import androidx.compose.material.icons.automirrored.outlined.ArrowBack | |
| 25 | +import androidx.compose.material3.Button | |
| 26 | +import androidx.compose.material3.ButtonDefaults | |
| 27 | +import androidx.compose.material3.CircularProgressIndicator | |
| 28 | +import androidx.compose.material3.Icon | |
| 29 | +import androidx.compose.material3.IconButton | |
| 30 | +import androidx.compose.material3.MaterialTheme | |
| 31 | +import androidx.compose.material3.OutlinedButton | |
| 32 | +import androidx.compose.material3.Text | |
| 33 | +import androidx.compose.runtime.Composable | |
| 34 | +import androidx.compose.runtime.LaunchedEffect | |
| 35 | +import androidx.compose.runtime.collectAsState | |
| 36 | +import androidx.compose.runtime.getValue | |
| 37 | +import androidx.compose.runtime.mutableStateOf | |
| 38 | +import androidx.compose.runtime.remember | |
| 39 | +import androidx.compose.runtime.setValue | |
| 40 | +import androidx.compose.ui.Alignment | |
| 41 | +import androidx.compose.ui.Modifier | |
| 42 | +import androidx.compose.ui.graphics.asImageBitmap | |
| 43 | +import androidx.compose.ui.layout.ContentScale | |
| 44 | +import androidx.compose.ui.platform.LocalContext | |
| 45 | +import androidx.compose.ui.res.stringResource | |
| 46 | +import androidx.compose.ui.unit.dp | |
| 47 | +import androidx.core.content.FileProvider | |
| 48 | +import fr.ebii.card2vcf.R | |
| 49 | +import fr.ebii.card2vcf.contact.ContactCard | |
| 50 | +import fr.ebii.card2vcf.ui.ContactDraftFields | |
| 51 | +import fr.ebii.card2vcf.ui.theme.Bordure | |
| 52 | +import fr.ebii.card2vcf.ui.theme.Encre | |
| 53 | +import fr.ebii.card2vcf.ui.theme.Fond | |
| 54 | +import fr.ebii.card2vcf.ui.theme.Ink | |
| 55 | +import fr.ebii.card2vcf.ui.theme.OnPrimary | |
| 56 | +import fr.ebii.card2vcf.ui.theme.Surface | |
| 57 | +import fr.ebii.card2vcf.ui.theme.TexteFaible | |
| 58 | +import java.io.File | |
| 59 | + | |
| 60 | +private val Square = RoundedCornerShape(0.dp) | |
| 61 | + | |
| 62 | +@Composable | |
| 63 | +fun ContactEditScreen( | |
| 64 | + viewModel: ContactEditViewModel, | |
| 65 | + onBack: () -> Unit, | |
| 66 | + onSaved: () -> Unit, | |
| 67 | + onRescan: (contactId: Long) -> Unit, | |
| 68 | + modifier: Modifier = Modifier, | |
| 69 | +) { | |
| 70 | + val card by viewModel.card.collectAsState() | |
| 71 | + val profilePreview by viewModel.profilePreview.collectAsState() | |
| 72 | + val loaded by viewModel.loaded.collectAsState() | |
| 73 | + val saveDone by viewModel.saveDone.collectAsState() | |
| 74 | + | |
| 75 | + LaunchedEffect(saveDone) { | |
| 76 | + if (saveDone) onSaved() | |
| 77 | + } | |
| 78 | + | |
| 79 | + val context = LocalContext.current | |
| 80 | + var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) } | |
| 81 | + | |
| 82 | + val takePicture = rememberLauncherForActivityResult( | |
| 83 | + ActivityResultContracts.TakePicture(), | |
| 84 | + ) { success -> | |
| 85 | + if (!success) return@rememberLauncherForActivityResult | |
| 86 | + val uri = pendingCaptureUri ?: return@rememberLauncherForActivityResult | |
| 87 | + context.contentResolver.openInputStream(uri)?.use { stream -> | |
| 88 | + BitmapFactory.decodeStream(stream)?.let(viewModel::setProfileBitmap) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + val pickImage = rememberLauncherForActivityResult( | |
| 93 | + ActivityResultContracts.GetContent(), | |
| 94 | + ) { uri -> | |
| 95 | + uri ?: return@rememberLauncherForActivityResult | |
| 96 | + context.contentResolver.openInputStream(uri)?.use { stream -> | |
| 97 | + BitmapFactory.decodeStream(stream)?.let(viewModel::setProfileBitmap) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + Column( | |
| 102 | + modifier | |
| 103 | + .fillMaxSize() | |
| 104 | + .background(Fond), | |
| 105 | + ) { | |
| 106 | + Row( | |
| 107 | + Modifier | |
| 108 | + .fillMaxWidth() | |
| 109 | + .background(Surface) | |
| 110 | + .padding(horizontal = 4.dp, vertical = 4.dp), | |
| 111 | + verticalAlignment = Alignment.CenterVertically, | |
| 112 | + ) { | |
| 113 | + IconButton(onClick = onBack) { | |
| 114 | + Icon( | |
| 115 | + Icons.AutoMirrored.Outlined.ArrowBack, | |
| 116 | + contentDescription = stringResource(R.string.scan_retour), | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + Text( | |
| 120 | + stringResource(R.string.edit_titre), | |
| 121 | + style = MaterialTheme.typography.titleMedium, | |
| 122 | + color = Encre, | |
| 123 | + modifier = Modifier.padding(start = 4.dp), | |
| 124 | + ) | |
| 125 | + } | |
| 126 | + | |
| 127 | + if (!loaded) { | |
| 128 | + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | |
| 129 | + CircularProgressIndicator() | |
| 130 | + } | |
| 131 | + return@Column | |
| 132 | + } | |
| 133 | + | |
| 134 | + Column( | |
| 135 | + Modifier | |
| 136 | + .weight(1f) | |
| 137 | + .fillMaxWidth() | |
| 138 | + .verticalScroll(rememberScrollState()) | |
| 139 | + .padding(horizontal = 18.dp, vertical = 12.dp), | |
| 140 | + verticalArrangement = Arrangement.spacedBy(14.dp), | |
| 141 | + ) { | |
| 142 | + Text(stringResource(R.string.edit_photo_profil), color = Encre) | |
| 143 | + val preview = profilePreview | |
| 144 | + if (preview != null) { | |
| 145 | + Image( | |
| 146 | + bitmap = preview.asImageBitmap(), | |
| 147 | + contentDescription = stringResource(R.string.fiche_photo_profil), | |
| 148 | + modifier = Modifier | |
| 149 | + .size(96.dp) | |
| 150 | + .border(1.dp, Bordure, Square), | |
| 151 | + contentScale = ContentScale.Crop, | |
| 152 | + ) | |
| 153 | + } else { | |
| 154 | + Box( | |
| 155 | + Modifier | |
| 156 | + .size(96.dp) | |
| 157 | + .border(1.dp, Bordure, Square) | |
| 158 | + .background(Surface), | |
| 159 | + ) | |
| 160 | + } | |
| 161 | + Row( | |
| 162 | + Modifier.fillMaxWidth(), | |
| 163 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 164 | + ) { | |
| 165 | + OutlinedButton( | |
| 166 | + onClick = { | |
| 167 | + val dir = File(context.cacheDir, "images").also { it.mkdirs() } | |
| 168 | + val file = File(dir, "profile_${System.currentTimeMillis()}.jpg") | |
| 169 | + val uri = FileProvider.getUriForFile( | |
| 170 | + context, | |
| 171 | + "${context.packageName}.fileprovider", | |
| 172 | + file, | |
| 173 | + ) | |
| 174 | + pendingCaptureUri = uri | |
| 175 | + takePicture.launch(uri) | |
| 176 | + }, | |
| 177 | + modifier = Modifier.weight(1f), | |
| 178 | + shape = Square, | |
| 179 | + ) { | |
| 180 | + Text(stringResource(R.string.edit_prendre_photo)) | |
| 181 | + } | |
| 182 | + OutlinedButton( | |
| 183 | + onClick = { pickImage.launch("image/*") }, | |
| 184 | + modifier = Modifier.weight(1f), | |
| 185 | + shape = Square, | |
| 186 | + ) { | |
| 187 | + Text(stringResource(R.string.edit_choisir_photo)) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + | |
| 191 | + ContactDraftFields(card = card, onChange = viewModel::updateCard) | |
| 192 | + | |
| 193 | + OutlinedButton( | |
| 194 | + onClick = { onRescan(viewModel.contactId) }, | |
| 195 | + modifier = Modifier.fillMaxWidth(), | |
| 196 | + shape = Square, | |
| 197 | + ) { | |
| 198 | + Text(stringResource(R.string.edit_rescanner)) | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + Button( | |
| 203 | + onClick = viewModel::save, | |
| 204 | + modifier = Modifier | |
| 205 | + .fillMaxWidth() | |
| 206 | + .padding(horizontal = 18.dp, vertical = 12.dp), | |
| 207 | + shape = Square, | |
| 208 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 209 | + ) { | |
| 210 | + Text(stringResource(R.string.edit_enregistrer)) | |
| 211 | + } | |
| 212 | + } | |
| 213 | +} | |
| 214 | + | |
| 215 | +@Composable | |
| 216 | +fun ManualContactScreen( | |
| 217 | + onSave: (ContactCard) -> Unit, | |
| 218 | + onBack: () -> Unit, | |
| 219 | + modifier: Modifier = Modifier, | |
| 220 | +) { | |
| 221 | + var card by remember { mutableStateOf(ContactCard()) } | |
| 222 | + | |
| 223 | + Column( | |
| 224 | + modifier | |
| 225 | + .fillMaxSize() | |
| 226 | + .background(Fond), | |
| 227 | + ) { | |
| 228 | + Row( | |
| 229 | + Modifier | |
| 230 | + .fillMaxWidth() | |
| 231 | + .background(Surface) | |
| 232 | + .padding(horizontal = 4.dp, vertical = 4.dp), | |
| 233 | + verticalAlignment = Alignment.CenterVertically, | |
| 234 | + ) { | |
| 235 | + IconButton(onClick = onBack) { | |
| 236 | + Icon( | |
| 237 | + Icons.AutoMirrored.Outlined.ArrowBack, | |
| 238 | + contentDescription = stringResource(R.string.scan_retour), | |
| 239 | + ) | |
| 240 | + } | |
| 241 | + Text( | |
| 242 | + stringResource(R.string.manual_titre), | |
| 243 | + style = MaterialTheme.typography.titleMedium, | |
| 244 | + color = Encre, | |
| 245 | + modifier = Modifier.padding(start = 4.dp), | |
| 246 | + ) | |
| 247 | + } | |
| 248 | + Column( | |
| 249 | + Modifier | |
| 250 | + .weight(1f) | |
| 251 | + .fillMaxWidth() | |
| 252 | + .verticalScroll(rememberScrollState()) | |
| 253 | + .padding(horizontal = 18.dp, vertical = 12.dp), | |
| 254 | + verticalArrangement = Arrangement.spacedBy(14.dp), | |
| 255 | + ) { | |
| 256 | + Text(stringResource(R.string.manual_sous_titre), color = TexteFaible) | |
| 257 | + ContactDraftFields(card = card, onChange = { card = it }) | |
| 258 | + Spacer(Modifier.height(8.dp)) | |
| 259 | + } | |
| 260 | + Button( | |
| 261 | + onClick = { onSave(card) }, | |
| 262 | + modifier = Modifier | |
| 263 | + .fillMaxWidth() | |
| 264 | + .padding(horizontal = 18.dp, vertical = 12.dp), | |
| 265 | + shape = Square, | |
| 266 | + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary), | |
| 267 | + ) { | |
| 268 | + Text(stringResource(R.string.edit_enregistrer)) | |
| 269 | + } | |
| 270 | + } | |
| 271 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditViewModel.kt
+65
-0
@@ -0,0 +1,65 @@
| 1 | +package fr.ebii.card2vcf.ui.contact | |
| 2 | + | |
| 3 | +import android.graphics.Bitmap | |
| 4 | +import android.graphics.BitmapFactory | |
| 5 | +import androidx.lifecycle.ViewModel | |
| 6 | +import androidx.lifecycle.viewModelScope | |
| 7 | +import fr.ebii.card2vcf.contact.ContactCard | |
| 8 | +import fr.ebii.card2vcf.data.ContactRepository | |
| 9 | +import fr.ebii.card2vcf.data.toCard | |
| 10 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 11 | +import kotlinx.coroutines.flow.StateFlow | |
| 12 | +import kotlinx.coroutines.flow.asStateFlow | |
| 13 | +import kotlinx.coroutines.launch | |
| 14 | + | |
| 15 | +class ContactEditViewModel( | |
| 16 | + private val repository: ContactRepository, | |
| 17 | + val contactId: Long, | |
| 18 | +) : ViewModel() { | |
| 19 | + | |
| 20 | + private val _card = MutableStateFlow(ContactCard()) | |
| 21 | + val card: StateFlow<ContactCard> = _card.asStateFlow() | |
| 22 | + | |
| 23 | + private val _profilePreview = MutableStateFlow<Bitmap?>(null) | |
| 24 | + val profilePreview: StateFlow<Bitmap?> = _profilePreview.asStateFlow() | |
| 25 | + | |
| 26 | + private val _loaded = MutableStateFlow(false) | |
| 27 | + val loaded: StateFlow<Boolean> = _loaded.asStateFlow() | |
| 28 | + | |
| 29 | + private val _saveDone = MutableStateFlow(false) | |
| 30 | + val saveDone: StateFlow<Boolean> = _saveDone.asStateFlow() | |
| 31 | + | |
| 32 | + private var pendingProfile: Bitmap? = null | |
| 33 | + | |
| 34 | + init { | |
| 35 | + viewModelScope.launch { | |
| 36 | + val entity = repository.getById(contactId) ?: return@launch | |
| 37 | + _card.value = entity.toCard() | |
| 38 | + entity.profileImagePath?.takeIf { it.isNotBlank() }?.let { path -> | |
| 39 | + _profilePreview.value = BitmapFactory.decodeFile(path) | |
| 40 | + } | |
| 41 | + _loaded.value = true | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + fun updateCard(card: ContactCard) { | |
| 46 | + _card.value = card | |
| 47 | + } | |
| 48 | + | |
| 49 | + fun setProfileBitmap(bitmap: Bitmap) { | |
| 50 | + pendingProfile = bitmap | |
| 51 | + _profilePreview.value = bitmap | |
| 52 | + } | |
| 53 | + | |
| 54 | + fun save() { | |
| 55 | + viewModelScope.launch { | |
| 56 | + repository.updateFromCard( | |
| 57 | + id = contactId, | |
| 58 | + card = _card.value, | |
| 59 | + cardBitmap = null, | |
| 60 | + profileBitmap = pendingProfile, | |
| 61 | + ) | |
| 62 | + _saveDone.value = true | |
| 63 | + } | |
| 64 | + } | |
| 65 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+45
-5
@@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Box
| 4 | 4 | import androidx.compose.foundation.layout.fillMaxSize |
| 5 | 5 | import androidx.compose.material3.Text |
| 6 | 6 | import androidx.compose.runtime.Composable |
| 7 | +import androidx.compose.runtime.LaunchedEffect | |
| 7 | 8 | import androidx.compose.runtime.remember |
| 9 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 8 | 10 | import androidx.compose.ui.Alignment |
| 9 | 11 | import androidx.compose.ui.Modifier |
| 10 | 12 | import androidx.compose.ui.platform.LocalContext |
@@ -23,8 +25,12 @@ import fr.ebii.card2vcf.ui.ScanCarteScreen
| 23 | 25 | import fr.ebii.card2vcf.ui.ScanCarteViewModel |
| 24 | 26 | import fr.ebii.card2vcf.ui.carnet.CarnetScreen |
| 25 | 27 | import fr.ebii.card2vcf.ui.carnet.CarnetViewModel |
| 28 | +import fr.ebii.card2vcf.ui.contact.ContactEditScreen | |
| 29 | +import fr.ebii.card2vcf.ui.contact.ContactEditViewModel | |
| 26 | 30 | import fr.ebii.card2vcf.ui.contact.ContactPagerScreen |
| 27 | 31 | import fr.ebii.card2vcf.ui.contact.ContactPagerViewModel |
| 32 | +import fr.ebii.card2vcf.ui.contact.ManualContactScreen | |
| 33 | +import kotlinx.coroutines.launch | |
| 28 | 34 | |
| 29 | 35 | object Routes { |
| 30 | 36 | const val Carnet = "carnet" |
@@ -57,12 +63,14 @@ fun Card2vcfNavHost(
| 57 | 63 | ) { |
| 58 | 64 | val carnetVm = remember(repository) { CarnetViewModel(repository) } |
| 59 | 65 | val context = LocalContext.current |
| 60 | - val scanVm = remember { | |
| 66 | + val scanVm = remember(repository) { | |
| 61 | 67 | ScanCarteViewModel( |
| 62 | 68 | scanEngine = OpenCvScanEngine(), |
| 63 | 69 | ocrEngine = TesseractOcrEngine(context.applicationContext), |
| 70 | + repository = repository, | |
| 64 | 71 | ) |
| 65 | 72 | } |
| 73 | + val scope = rememberCoroutineScope() | |
| 66 | 74 | |
| 67 | 75 | NavHost( |
| 68 | 76 | navController = navController, |
@@ -114,8 +122,18 @@ fun Card2vcfNavHost(
| 114 | 122 | navArgument("contactId") { type = NavType.LongType }, |
| 115 | 123 | ), |
| 116 | 124 | ) { entry -> |
| 117 | - val id = entry.arguments?.getLong("contactId") | |
| 118 | - PlaceholderRoute("Édition #$id") | |
| 125 | + val id = entry.arguments?.getLong("contactId") ?: return@composable | |
| 126 | + val editVm = remember(repository, id) { | |
| 127 | + ContactEditViewModel(repository, id) | |
| 128 | + } | |
| 129 | + ContactEditScreen( | |
| 130 | + viewModel = editVm, | |
| 131 | + onBack = { navController.popBackStack() }, | |
| 132 | + onSaved = { navController.popBackStack() }, | |
| 133 | + onRescan = { contactId -> | |
| 134 | + navController.navigate(Routes.scan(contactId)) | |
| 135 | + }, | |
| 136 | + ) | |
| 119 | 137 | } |
| 120 | 138 | composable( |
| 121 | 139 | route = Routes.Duplicates, |
@@ -135,16 +153,38 @@ fun Card2vcfNavHost(
| 135 | 153 | defaultValue = null |
| 136 | 154 | }, |
| 137 | 155 | ), |
| 138 | - ) { | |
| 156 | + ) { entry -> | |
| 157 | + val editId = entry.arguments?.getString("editId")?.toLongOrNull() | |
| 158 | + LaunchedEffect(editId) { | |
| 159 | + scanVm.editingContactId = editId | |
| 160 | + scanVm.reset() | |
| 161 | + } | |
| 139 | 162 | ScanCarteScreen( |
| 140 | 163 | viewModel = scanVm, |
| 141 | 164 | onCreateContact = onCreateContact, |
| 142 | 165 | onExportVcf = onExportVcf, |
| 166 | + onContactUpdated = { contactId -> | |
| 167 | + scanVm.editingContactId = null | |
| 168 | + scanVm.reset() | |
| 169 | + navController.navigate(Routes.fiche(contactId)) { | |
| 170 | + popUpTo(Routes.Carnet) { inclusive = false } | |
| 171 | + } | |
| 172 | + }, | |
| 143 | 173 | onBack = { navController.popBackStack() }, |
| 144 | 174 | ) |
| 145 | 175 | } |
| 146 | 176 | composable(Routes.Manual) { |
| 147 | - PlaceholderRoute("Saisie manuelle") | |
| 177 | + ManualContactScreen( | |
| 178 | + onBack = { navController.popBackStack() }, | |
| 179 | + onSave = { card -> | |
| 180 | + scope.launch { | |
| 181 | + val id = repository.insertFromCard(card, null, null) | |
| 182 | + navController.navigate(Routes.fiche(id)) { | |
| 183 | + popUpTo(Routes.Carnet) { inclusive = false } | |
| 184 | + } | |
| 185 | + } | |
| 186 | + }, | |
| 187 | + ) | |
| 148 | 188 | } |
| 149 | 189 | composable(Routes.Draft) { |
| 150 | 190 | PlaceholderRoute("Brouillon") |
M
android/app/src/main/res/values-en/strings.xml
+10
-0
@@ -42,4 +42,14 @@
| 42 | 42 | <string name="fiche_doublons">%d duplicate(s)</string> |
| 43 | 43 | <string name="fiche_photo_profil">Profile photo</string> |
| 44 | 44 | <string name="fiche_photo_carte">Card photo</string> |
| 45 | + | |
| 46 | + <string name="edit_titre">Edit contact</string> | |
| 47 | + <string name="edit_enregistrer">Save</string> | |
| 48 | + <string name="edit_mettre_a_jour">Update</string> | |
| 49 | + <string name="edit_rescanner">Rescan card</string> | |
| 50 | + <string name="edit_photo_profil">Profile photo</string> | |
| 51 | + <string name="edit_prendre_photo">Take photo</string> | |
| 52 | + <string name="edit_choisir_photo">Choose photo</string> | |
| 53 | + <string name="manual_titre">New contact</string> | |
| 54 | + <string name="manual_sous_titre">Fill in the fields, then save to the address book.</string> | |
| 45 | 55 | </resources> |
M
android/app/src/main/res/values/strings.xml
+10
-0
@@ -42,4 +42,14 @@
| 42 | 42 | <string name="fiche_doublons">%d doublon(s)</string> |
| 43 | 43 | <string name="fiche_photo_profil">Photo de profil</string> |
| 44 | 44 | <string name="fiche_photo_carte">Photo de la carte</string> |
| 45 | + | |
| 46 | + <string name="edit_titre">Modifier le contact</string> | |
| 47 | + <string name="edit_enregistrer">Enregistrer</string> | |
| 48 | + <string name="edit_mettre_a_jour">Mettre à jour</string> | |
| 49 | + <string name="edit_rescanner">Rescanner la carte</string> | |
| 50 | + <string name="edit_photo_profil">Photo de profil</string> | |
| 51 | + <string name="edit_prendre_photo">Prendre une photo</string> | |
| 52 | + <string name="edit_choisir_photo">Choisir une photo</string> | |
| 53 | + <string name="manual_titre">Nouveau contact</string> | |
| 54 | + <string name="manual_sous_titre">Saisissez les champs, puis enregistrez dans le carnet.</string> | |
| 45 | 55 | </resources> |
M
android/app/src/main/res/xml/file_paths.xml
+1
-0
@@ -1,4 +1,5 @@
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | 2 | <paths> |
| 3 | 3 | <cache-path name="vcf" path="vcf/" /> |
| 4 | + <cache-path name="images" path="images/" /> | |
| 4 | 5 | </paths> |
GitRust