Écrans Agents¶
Documentation des écrans de l'application UrbaFix Agents.
LoginScreen¶
Écran de connexion des agents.
Composants¶
| Composant | Description |
|---|---|
| Logo | Logo UrbaFix centré |
| Titre | "Agent de Collectivité" |
| TextField avec validation | |
| Password | TextField masqué |
| Bouton | "Se connecter" |
| Erreur | Snackbar pour les erreurs |
États¶
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
Validation¶
| Champ | Règle |
|---|---|
| Format email valide | |
| Password | Minimum 8 caractères |
PinLockScreen¶
Verrouillage par code PIN présenté aux reprises de l'app (session JWT déjà valide, évite de resaisir email/mot de passe).
États¶
data class PinLockUiState(
val enteredDigits: String = "",
val error: String? = null,
val isUnlocked: Boolean = false,
val isWiped: Boolean = false,
val isVerifying: Boolean = false
)
Vérification et animation de pulsation¶
PinHasher.verify() (PBKDF2WithHmacSHA256) est volontairement lent. Pendant isVerifying :
- Les 4 points s'animent en opacité alternée (1 → 0.25, 450ms,
RepeatMode.Reverse) pour signaler que la saisie est prise en compte - Le clavier numérique est désactivé (évite une saisie parasite, ne sert pas à accélérer le hash)
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.25f,
animationSpec = infiniteRepeatable(tween(450, easing = LinearEasing), RepeatMode.Reverse)
)
Politique de verrouillage¶
| Règle | Valeur |
|---|---|
| Longueur du PIN | 4 chiffres |
Tentatives max (PinLockoutPolicy.MAX_ATTEMPTS) |
5 |
| Échec | Message "Code incorrect (N tentative(s) restante(s))" |
| 5e échec | PinCheckOutcome.LockedOut → session effacée (onWipedOut) |
DashboardScreen¶
Tableau de bord avec statistiques.
Layout¶
┌─────────────────────────────────────┐
│ STATISTIQUES │
├─────────┬─────────┬─────────────────┤
│ Nouveau │ Assigné │ En cours │
│ 25 │ 30 │ 45 │
├─────────┴─────────┴─────────────────┤
│ Résolu │ Clos │ Mes incidents │
│ 40 │ 10 │ 12 │
├─────────────────────────────────────┤
│ DERNIERS INCIDENTS │
├─────────────────────────────────────┤
│ ● Voirie - Rue de la Paix HIGH │
│ ● Éclairage - Avenue Foch NORMAL │
│ ● Propreté - Place Garibaldi LOW │
│ ● Voirie - Boulevard Hugo URGENT │
│ ● Espaces verts - Parc... NORMAL │
└─────────────────────────────────────┘
StatCard¶
Seule la tuile "Mes assignés" est cliquable (onClick non-null) : elle bascule sur IncidentListScreen avec le filtre "Assigné à moi" pré-appliqué (IncidentListViewModel.setMineFilter(true)), réinitialisé après consommation via un état porté par MainActivity. Les 4 autres tuiles ne sont pas cliquables.
@Composable
private fun StatCard(
title: String,
value: Int,
icon: ImageVector,
color: Color,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
)
IncidentListScreen¶
Liste des incidents avec filtres.
Filtres¶
| Filtre | Type | Valeurs |
|---|---|---|
| Assigné à moi | Chip (showOnlyMine) |
filtre en mémoire sur incidents.assignedAgentId == myAgentId, combiné aux autres filtres |
| Statut | Chips | NEW, ASSIGNED, IN_PROGRESS, ATTENTE, RESOLVED, CLOSED |
| Priorité | Chips | LOW, NORMAL, HIGH, URGENT |
| Recherche | TextField | Adresse, description |
Le chip "Assigné à moi" s'applique par-dessus la requête déjà active (recherche ou statut), pas en remplacement.
IncidentCard¶
┌─────────────────────────────────────┐
│ ● Voirie HIGH │ ← bordure primaire 2dp
│ 12 Rue de la Paix, Nice 👤 │ + icône personne si
│ Nid-de-poule dangereux sur la... │ assigné à l'agent connecté
│ 📷 2 💬 3 15/01/2026 10:30 │
└─────────────────────────────────────┘
IncidentCard(isAssignedToMe: Boolean) ajoute une bordure MaterialTheme.colorScheme.primary (2dp) et un badge Icons.Default.Person à côté du PriorityBadge quand l'incident est assigné à l'agent connecté.
Pull-to-Refresh¶
val pullRefreshState = rememberPullRefreshState(
refreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
IncidentDetailScreen¶
Détail complet d'un incident.
Layout¶
┌─────────────────────────────────────┐
│ Voirie - Nid-de-poule [X] │
├─────────────────────────────────────┤
│ Statut: ● En cours Priorité: 🔴│
│ │
│ 📍 12 Rue de la Paix, 06000 Nice │
│ ┌─────────────────────────────────┐ │
│ │ mini-carte (180dp) │ │
│ └─────────────────────────────────┘ │
│ [ S'y rendre ] │
│ 📅 15/01/2026 10:30 │
│ │
│ Équipe assignée: Marie M. ★, Luc P. │
│ │
│ Description: │
│ Nid-de-poule dangereux sur la │
│ chaussée, risque pour les cyclistes │
├─────────────────────────────────────┤
│ Photos: │
│ [📷] [📷] [📷] │
├─────────────────────────────────────┤
│ Commentaires: │
│ ┌─────────────────────────────────┐ │
│ │ Marie M. - 16/01 09:00 │ │
│ │ Intervention prévue demain. │ │
│ └─────────────────────────────────┘ │
├─────────────────────────────────────┤
│ [Changer statut] [Ajouter commentaire]
│ [Ajouter photo] │
└─────────────────────────────────────┘
Mini-carte + "S'y rendre"¶
Affichée uniquement si l'incident a des coordonnées GPS (latitude/longitude non nulles), sous l'adresse.
@Composable
private fun IncidentMiniMap(latitude: Double, longitude: Double, modifier: Modifier = Modifier)
- osmdroid
MapViewstatique :setMultiTouchControls(false),setBuiltInZoomControls(false)(évite les conflits de geste avec le scroll duLazyColumnparent), zoom fixe 16, marqueur centré - Bouton "S'y rendre" :
Intent(ACTION_VIEW, Uri.parse("geo:$lat,$lng?q=$lat,$lng(adresse)"))— URIgeo:générique (résolue par l'app de navigation installée), pas de dépendance à Google Maps ActivityNotFoundException(aucune app de navigation installée) →Toast"Aucune application de navigation installée"
Équipe assignée¶
Liste des agents de assigned_agents (nom, prénom, is_referent) ; le référent est mis en avant (icône étoile).
Actions¶
| Action | Dialog | Données |
|---|---|---|
| Changer statut | StatusDialog | Nouveau statut + priorité ; motif + note + intervenant si ATTENTE |
| Commentaire | CommentDialog | Texte + checkbox interne |
| Photo | Caméra | Photo + GPS |
StatusDialog¶
Le statut ATTENTE affiche des champs additionnels : sélecteur de motif (motifs_attente, requis), note libre, sélecteur d'intervenant contacté (optionnel, depuis le carnet de contacts).
@Composable
fun StatusDialog(
currentStatus: IncidentStatus,
currentPriority: IncidentPriority,
canEditPriority: Boolean,
onConfirm: (IncidentStatus, IncidentPriority, motifId: Long?, note: String?, contactId: Long?) -> Unit,
onDismiss: () -> Unit
)
canEditPriority reflète mairies.priorite_agents_autorise (revalidé côté serveur, ce flag n'est qu'un indice UI).
MapScreen¶
Carte des incidents.
Configuration osmdroid¶
Configuration.getInstance().apply {
userAgentValue = "UrbaFixAgents/1.0"
osmdroidTileCache = File(context.cacheDir, "osmdroid")
}
Marqueurs¶
| Priorité | Couleur |
|---|---|
| LOW | Vert |
| NORMAL | Bleu |
| HIGH | Orange |
| URGENT | Rouge |
Les incidents assignés à l'agent connecté (incident.assignedAgentId == myAgentId) reçoivent un anneau bleu épais (8dp, même bleu que le marqueur "ma position") au lieu de l'anneau blanc par défaut (4dp), superposé à la couleur de priorité — createColoredMarker(color, highlighted = true).
Géolocalisation¶
val locationManager = context.getSystemService<LocationManager>()
val locationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
mapView.controller.setCenter(
GeoPoint(location.latitude, location.longitude)
)
}
}
ContactsScreen¶
Carnet de contacts des intervenants de la mairie (ou de toutes les mairies de la collectivité pour un agent EPCI).
┌─────────────────────────────────────┐
│ Contacts │
├─────────────────────────────────────┤
│ Garage Dupont Mécanique │
│ 📞 04 93 00 00 00 │
│ ✉️ contact@garage-dupont.test │
├─────────────────────────────────────┤
│ Serrurier Martin Serrurerie │
│ 📞 04 93 00 00 01 🚨 06 00 00 00 01│
└─────────────────────────────────────┘
- Source :
GET /api/agents/intervenants/list.php(sansincident_id) - Champs : nom, catégorie, téléphone, téléphone urgence (optionnel), email, notes
- Même endpoint (avec
incident_id) alimente le sélecteur d'intervenant du dialogue ATTENTE surIncidentDetailScreen
ProfileScreen¶
Profil de l'agent.
Layout¶
┌─────────────────────────────────────┐
│ 👤 │
│ Jean Dupont │
│ agent@collectivite.test │
├─────────────────────────────────────┤
│ Collectivité │
│ Ville de Nice (Mairie) │
├─────────────────────────────────────┤
│ Mes statistiques │
│ Incidents traités: 45 │
│ Commentaires: 123 │
│ Photos ajoutées: 67 │
├─────────────────────────────────────┤
│ Synchronisation │
│ Dernière sync: 18/01/2026 14:30 │
│ Actions en attente: 3 │
│ [Synchroniser maintenant] │
├─────────────────────────────────────┤
│ [Se déconnecter] │
└─────────────────────────────────────┘
États¶
data class ProfileUiState(
val agentName: String? = null,
val agentEmail: String? = null,
val collectiviteName: String? = null,
val collectiviteType: CollectiviteType? = null,
val stats: AgentStats? = null,
val lastSyncTime: Long? = null,
val pendingActionsCount: Int = 0,
val isSyncing: Boolean = false
)
Notifications Push¶
Notification à l'agent lors de l'affectation d'un incident, via UnifiedPush (distributeur recommandé : ntfy).
PushSetupGate¶
Composable monté dans MainNavigation, au même niveau que LocationTrackingPermissionGate, exécuté une fois par session :
- Demande
POST_NOTIFICATIONS(Android 13+) — seul point d'appel de cette permission dans l'app PushInitializer.checkPushState()→NoDistributor(afficheUnifiedPushSetupDialog),NotRegistered(auto-enregistrement), ouReady/SetupSkipped
Pourquoi POST_NOTIFICATIONS est demandée ici, pas dans le suivi de position
Avant fix, la permission n'était demandée que depuis LocationTrackingPermissionGate, conditionnée à l'activation du suivi de position par la mairie. Suivi désactivé (cas courant en test) = permission jamais demandée = Android bloque silencieusement toutes les notifications de l'app (importance=NONE), y compris le push d'affectation qui n'a pourtant rien à voir avec le suivi. PushSetupGate tourne indépendamment et systématiquement.
Android n'autorise qu'une demande de permission à la fois par Activity : POST_NOTIFICATIONS (PushSetupGate) et la localisation (LocationTrackingPermissionGate) ne sont donc chacune demandées que depuis leur propre gate, jamais dupliquées.
Enregistrement UnifiedPush (UnifiedPushManager)¶
- Aucun distributeur installé → dialogue d'installation, F-Droid en action principale (ntfy, cible aussi les ROM dégooglisées), Play Store en secondaire
- Un seul distributeur → sélection et enregistrement automatiques (
UnifiedPush.registerApp) - Après l'enregistrement, ntfy est lancé automatiquement ~800ms (
launchNtfyIfInstalled) puis l'app reprend le premier plan : au premier lancement, ntfy n'a parfois jamais initialisé sa connexion à son serveur, et la demande d'enregistrement reste alors sans réponse (onNewEndpointjamais appelé) tant qu'il n'a pas été ouvert une fois. Android n'offrant aucun moyen de démarrer une app sans l'afficher, ce flash-and-return est la meilleure approximation d'un lancement "en arrière-plan"
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
push/UnifiedPushManager.kt |
Enregistrement, détection distributeur, lancement ntfy |
push/PushInitializer.kt |
État global (NoDistributor/NotRegistered/Ready/SetupSkipped) |
push/AgentPushReceiver.kt |
Réception des push, stockage endpoint |
ui/components/UnifiedPushSetupDialog.kt |
Dialogue d'installation du distributeur |
MainActivity.kt (PushSetupGate) |
Permission + déclenchement de l'enregistrement |
Composants partagés¶
OfflineBanner¶
Bandeau affiché en mode hors-ligne.
@Composable
fun OfflineBanner(
pendingActionsCount: Int
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer
) {
Row {
Icon(Icons.Default.CloudOff)
Text("Mode hors-ligne")
if (pendingActionsCount > 0) {
Badge { Text("$pendingActionsCount") }
}
}
}
}
PriorityBadge¶
Badge coloré pour la priorité.
@Composable
fun PriorityBadge(priority: IncidentPriority) {
val color = when (priority) {
IncidentPriority.LOW -> Color.Green
IncidentPriority.NORMAL -> Color.Blue
IncidentPriority.HIGH -> Color(0xFFFFA500) // Orange
IncidentPriority.URGENT -> Color.Red
}
Surface(
color = color,
shape = RoundedCornerShape(4.dp)
) {
Text(priority.name, color = Color.White)
}
}
StatusChip¶
Chip cliquable pour les statuts.