Aller au contenu

É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é"
Email 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
Email 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 MapView statique : setMultiTouchControls(false), setBuiltInZoomControls(false) (évite les conflits de geste avec le scroll du LazyColumn parent), zoom fixe 16, marqueur centré
  • Bouton "S'y rendre" : Intent(ACTION_VIEW, Uri.parse("geo:$lat,$lng?q=$lat,$lng(adresse)")) — URI geo: 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 (sans incident_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 sur IncidentDetailScreen

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 :

  1. Demande POST_NOTIFICATIONS (Android 13+) — seul point d'appel de cette permission dans l'app
  2. PushInitializer.checkPushState() → NoDistributor (affiche UnifiedPushSetupDialog), NotRegistered (auto-enregistrement), ou Ready/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 (onNewEndpoint jamais 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.

@Composable
fun StatusChip(
    status: IncidentStatus,
    isSelected: Boolean,
    onClick: () -> Unit
)