diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/voices/RecordVoiceScreen.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/voices/RecordVoiceScreen.kt index 65f2113..d73c377 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/voices/RecordVoiceScreen.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/ui/voices/RecordVoiceScreen.kt @@ -36,6 +36,14 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.foundation.selection.selectable +import com.kazeia.admin.data.source.KazeiaProfilesClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -87,6 +95,21 @@ fun RecordVoiceScreen( } var qualityReport by remember { mutableStateOf(null) } + // Exclusivité (Phase 1) : portée de la voix choisie au moment d'enregistrer. + val profilesClient = remember { KazeiaProfilesClient(context.contentResolver) } + var profiles by remember { mutableStateOf>(emptyList()) } + var scopeChoice by remember { mutableStateOf("exclusive") } // exclusive|global|pending + var ownerProfileId by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + val list = withContext(Dispatchers.IO) { profilesClient.queryAll() } + val active = withContext(Dispatchers.IO) { profilesClient.activeProfileId() } + profiles = list + // Défaut = patient actif (cas courant : enregistrer la voix d'un proche + // POUR le patient actif de cette tablette). Aucun profil → bascule pending. + ownerProfileId = active ?: list.firstOrNull()?.id + if (list.isEmpty()) scopeChoice = "pending" + } + val permLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> permissionGranted = granted } @@ -139,6 +162,99 @@ fun RecordVoiceScreen( modifier = Modifier.fillMaxWidth() ) + // 1.5 Exclusivité : à qui cette voix est destinée (capture record-time, + // écrite dans le manifeste → Kazeia-central applique la politique). + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.rec_scope_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + val opts = listOf( + "exclusive" to stringResource(R.string.rec_scope_exclusive), + "global" to stringResource(R.string.rec_scope_global), + "pending" to stringResource(R.string.rec_scope_pending) + ) + opts.forEach { (value, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = scopeChoice == value, + enabled = state != AudioRecorder.State.RECORDING, + onClick = { scopeChoice = value } + ) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = scopeChoice == value, + onClick = { scopeChoice = value }, + enabled = state != AudioRecorder.State.RECORDING + ) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } + if (scopeChoice == "exclusive") { + Spacer(Modifier.height(8.dp)) + if (profiles.isEmpty()) { + Text( + stringResource(R.string.rec_scope_no_patients), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } else { + var expanded by remember { mutableStateOf(false) } + val owner = profiles.firstOrNull { it.id == ownerProfileId } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + if (state != AudioRecorder.State.RECORDING) expanded = it + } + ) { + OutlinedTextField( + value = owner?.displayName ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.rec_scope_patient_label)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.displayName) }, + onClick = { ownerProfileId = p.id; expanded = false } + ) + } + } + } + } + } else if (scopeChoice == "pending") { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.rec_scope_pending_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + // 2. Phrase de référence (vaut consentement enregistré) Card( colors = CardDefaults.cardColors( @@ -240,18 +356,23 @@ fun RecordVoiceScreen( } // 5. Bouton save : visible quand audio capturé ET qualité OK - val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true + val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true && + (scopeChoice != "exclusive" || ownerProfileId != null) if (state == AudioRecorder.State.FINISHED) { Button( onClick = { val samples = recorder.samples() val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE + val ownerName = profiles.firstOrNull { it.id == ownerProfileId }?.displayName val saved = runCatching { storage.save( displayName = voiceName.trim(), samples = samples, durationSeconds = durSec, - referenceText = referenceText + referenceText = referenceText, + scope = scopeChoice, + ownerProfileId = if (scopeChoice == "exclusive") ownerProfileId else null, + ownerName = if (scopeChoice == "exclusive") ownerName else null ) } scope.launch { diff --git a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/voice/VoiceStorage.kt b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/voice/VoiceStorage.kt index 1daeb6a..8838268 100644 --- a/kazeia-android/app-admin/src/main/java/com/kazeia/admin/voice/VoiceStorage.kt +++ b/kazeia-android/app-admin/src/main/java/com/kazeia/admin/voice/VoiceStorage.kt @@ -45,7 +45,15 @@ class VoiceStorage(private val context: Context) { displayName: String, samples: ShortArray, durationSeconds: Float, - referenceText: String + referenceText: String, + // Exclusivité (Phase 1, capture record-time). scope ∈ {exclusive, global, + // pending}. Lu par Kazeia-central à l'ingestion → localisation de + // déploiement + assignation Profile.voiceId (cf. VOICE_DEPLOYMENT_SPEC §4). + // exclusive → ownerProfileId requis ; pending → propriétaire à lier plus + // tard (ni déployée ni assignable tant que non résolu) ; global → tous. + scope: String = "pending", + ownerProfileId: String? = null, + ownerName: String? = null ): RecordedVoice { val ts = TS_FMT.format(System.currentTimeMillis()) val id = "${slug(displayName)}_$ts" @@ -63,6 +71,9 @@ class VoiceStorage(private val context: Context) { put("created_at", System.currentTimeMillis()) put("reference_text", referenceText) put("source_device", "tablet-admin-recording") + put("scope", scope) + ownerProfileId?.let { put("owner_profile_id", it) } + ownerName?.let { put("owner_name", it) } } manifest.writeText(json.toString(2)) diff --git a/kazeia-android/app-admin/src/main/res/values/strings.xml b/kazeia-android/app-admin/src/main/res/values/strings.xml index 23060fd..23c6aad 100644 --- a/kazeia-android/app-admin/src/main/res/values/strings.xml +++ b/kazeia-android/app-admin/src/main/res/values/strings.xml @@ -36,8 +36,15 @@ Nom de la voix Ex : Mamie, Pierre, Sophie Lisez la phrase suivante d\'une voix posée et naturelle - Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux. + Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Aujourd\'hui, le ciel est bleu et le vent souffle doucement. J\'aime écouter le chant joyeux des oiseaux, près d\'un feu paisible. Quel bonheur tranquille ! Et vous, qu\'est-ce qui vous rend vraiment heureux ? Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment. + À qui est destinée cette voix ? + Exclusive à un patient + Non-exclusive (tous les patients) + Lier à un patient plus tard + Patient propriétaire + Aucun patient chargé sur cette tablette — choisissez « Lier à un patient plus tard ». + La voix sera enregistrée mais ni déployée ni utilisable tant qu\'un patient ne lui sera pas assigné. Commencer l\'enregistrement Arrêter Réécouter