Aller au contenu

Application Android - Informations Officielles

Télécharger cette page en Markdown


Architecture

Architecture Android du module Informations Officielles


Entity Room

Fichier : data/local/entities/InfoOfficielleEntity.kt

@Entity(tableName = "informations_officielles")
data class InfoOfficielleEntity(
    @PrimaryKey
    val id: Long,

    @ColumnInfo(name = "mairie_id")
    val mairieId: Long,

    val titre: String,
    val description: String,

    @ColumnInfo(name = "type_info")
    val typeInfo: TypeInfo,

    val latitude: Double,
    val longitude: Double,
    val adresse: String?,

    @ColumnInfo(name = "rayon_metres")
    val rayonMetres: Int = 1000,

    val statut: StatutInfo,

    @ColumnInfo(name = "date_debut_publication")
    val dateDebutPublication: String?,

    @ColumnInfo(name = "date_publication")
    val datePublication: String?,

    @ColumnInfo(name = "date_expiration")
    val dateExpiration: String?,

    @ColumnInfo(name = "created_at")
    val createdAt: String,

    @ColumnInfo(name = "updated_at")
    val updatedAt: String
)

enum class TypeInfo {
    TRAVAUX,
    EVENEMENT,
    ALERTE,
    ANNONCE
}

enum class StatutInfo {
    BROUILLON,
    PROGRAMME,
    PUBLIE,
    EXPIRE,
    DESACTIVE
}

DAO

Fichier : data/local/dao/InfoOfficielleDao.kt

@Dao
interface InfoOfficielleDao {

    @Query("""
        SELECT * FROM informations_officielles
        WHERE statut = 'PUBLIE'
        ORDER BY created_at DESC
    """)
    fun getPublishedInfos(): Flow<List<InfoOfficielleEntity>>

    @Query("SELECT * FROM informations_officielles WHERE id = :id")
    suspend fun getById(id: Long): InfoOfficielleEntity?

    @Query("""
        SELECT * FROM informations_officielles
        WHERE statut = 'PUBLIE'
        AND type_info = :type
        ORDER BY created_at DESC
    """)
    fun getByType(type: TypeInfo): Flow<List<InfoOfficielleEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(infos: List<InfoOfficielleEntity>)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(info: InfoOfficielleEntity)

    @Query("DELETE FROM informations_officielles")
    suspend fun deleteAll()

    @Query("DELETE FROM informations_officielles WHERE id = :id")
    suspend fun deleteById(id: Long)
}

Repository

Fichier : data/repository/InfoOfficielleRepository.kt

class InfoOfficielleRepository(
    private val infoDao: InfoOfficielleDao,
    private val api: UrbafixApi,
    private val context: Context
) {
    /**
     * Flow des infos publiées (source locale)
     */
    fun getPublishedInfos(): Flow<List<InfoOfficielleEntity>> {
        return infoDao.getPublishedInfos()
    }

    /**
     * Synchronise les infos depuis l'API
     */
    suspend fun syncInfosFromApi(
        latitude: Double,
        longitude: Double,
        radius: Int = 10000
    ) {
        try {
            val response = api.getInfosOfficielles(latitude, longitude, radius)

            if (response.success) {
                // Mapper DTO vers Entity
                val entities = response.infos.map { dto ->
                    InfoOfficielleEntity(
                        id = dto.id,
                        mairieId = dto.mairie.id,
                        titre = dto.titre,
                        description = dto.description,
                        typeInfo = TypeInfo.valueOf(dto.typeInfo),
                        latitude = dto.latitude,
                        longitude = dto.longitude,
                        adresse = dto.adresse,
                        rayonMetres = dto.rayonMetres,
                        statut = StatutInfo.PUBLIE,
                        dateDebutPublication = null,
                        datePublication = dto.datePublication,
                        dateExpiration = dto.dateExpiration,
                        createdAt = dto.datePublication ?: "",
                        updatedAt = dto.datePublication ?: ""
                    )
                }

                // Remplacer les données locales
                infoDao.deleteAll()
                infoDao.insertAll(entities)

                Log.d(TAG, "Sync infos: ${entities.size} infos récupérées")
            }
        } catch (e: Exception) {
            Log.e(TAG, "Erreur sync infos: ${e.message}")
        }
    }

    /**
     * Récupère une info par ID
     */
    suspend fun getInfoById(id: Long): InfoOfficielleEntity? {
        return infoDao.getById(id)
    }

    companion object {
        private const val TAG = "InfoOfficielleRepo"
    }
}

API Retrofit

Fichier : data/remote/api/UrbafixApi.kt

interface UrbafixApi {

    @GET("api/get_infos.php")
    suspend fun getInfosOfficielles(
        @Query("latitude") latitude: Double,
        @Query("longitude") longitude: Double,
        @Query("rayon") rayon: Int = 10000
    ): InfosResponse
}

data class InfosResponse(
    val success: Boolean,
    val count: Int,
    val infos: List<InfoOfficielleDto>
)

data class InfoOfficielleDto(
    val id: Long,
    val titre: String,
    val description: String,
    @SerializedName("type_info")
    val typeInfo: String,
    val latitude: Double,
    val longitude: Double,
    val adresse: String?,
    @SerializedName("rayon_metres")
    val rayonMetres: Int,
    @SerializedName("date_publication")
    val datePublication: String?,
    @SerializedName("date_expiration")
    val dateExpiration: String?,
    val distance: Double?,
    val mairie: MairieDto,
    val photos: List<String>
)

data class MairieDto(
    val id: Long,
    val nom: String
)

UI Components

InfoOfficielleCard

Fichier : ui/components/InfoOfficielleCard.kt

@Composable
fun InfoOfficielleCard(
    info: InfoOfficielleEntity,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    val (emoji, color) = when (info.typeInfo) {
        TypeInfo.TRAVAUX -> "🚧" to Color(0xFFF59E0B)
        TypeInfo.EVENEMENT -> "🎉" to Color(0xFF8B5CF6)
        TypeInfo.ALERTE -> "⚠️" to Color(0xFFEF4444)
        TypeInfo.ANNONCE -> "📢" to Color(0xFF3B82F6)
    }

    Card(
        onClick = onClick,
        modifier = modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface
        )
    ) {
        Row(modifier = Modifier.padding(16.dp)) {
            // Barre colorée à gauche
            Box(
                modifier = Modifier
                    .width(4.dp)
                    .height(80.dp)
                    .background(color, RoundedCornerShape(2.dp))
            )

            Spacer(modifier = Modifier.width(12.dp))

            Column(modifier = Modifier.weight(1f)) {
                // Badge type
                Surface(
                    color = color.copy(alpha = 0.1f),
                    shape = RoundedCornerShape(4.dp)
                ) {
                    Text(
                        text = "$emoji ${info.typeInfo.name}",
                        style = MaterialTheme.typography.labelSmall,
                        color = color,
                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
                    )
                }

                Spacer(modifier = Modifier.height(8.dp))

                // Titre
                Text(
                    text = info.titre,
                    style = MaterialTheme.typography.titleMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )

                Spacer(modifier = Modifier.height(4.dp))

                // Description
                Text(
                    text = info.description,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )

                // Dates
                info.dateExpiration?.let { expiration ->
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "Expire le ${formatDate(expiration)}",
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.outline
                    )
                }
            }
        }
    }
}

HomeViewModel Integration

Fichier : ui/screens/home/HomeViewModel.kt

class HomeViewModel(
    private val repository: IncidentRepository,
    private val infoRepository: InfoOfficielleRepository,
    private val context: Context
) : ViewModel() {

    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        // Observer les infos officielles
        viewModelScope.launch {
            infoRepository.getPublishedInfos().collect { infos ->
                _uiState.update { it.copy(infosOfficielles = infos) }
            }
        }
    }

    fun syncAll(latitude: Double, longitude: Double) {
        viewModelScope.launch {
            // Sync incidents
            repository.syncIncidentsFromApi(latitude, longitude)

            // Sync infos officielles
            infoRepository.syncInfosFromApi(latitude, longitude)
        }
    }

    fun selectInfo(info: InfoOfficielleEntity) {
        _uiState.update {
            it.copy(
                selectedInfo = info,
                showInfoDetail = true
            )
        }
    }

    fun closeInfoDetail() {
        _uiState.update {
            it.copy(
                selectedInfo = null,
                showInfoDetail = false
            )
        }
    }
}

data class HomeUiState(
    val incidents: List<IncidentGroup> = emptyList(),
    val infosOfficielles: List<InfoOfficielleEntity> = emptyList(),
    val selectedInfo: InfoOfficielleEntity? = null,
    val showInfoDetail: Boolean = false,
    // ... autres champs
)

HomeScreen Integration

Fichier : ui/screens/home/HomeScreen.kt

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    Column {
        // Carte avec markers incidents + infos
        MapView(
            incidents = uiState.incidents,
            infos = uiState.infosOfficielles,
            onInfoClick = { viewModel.selectInfo(it) }
        )

        // Liste combinée
        LazyColumn {
            // Infos officielles en premier (priorité)
            items(uiState.infosOfficielles) { info ->
                InfoOfficielleCard(
                    info = info,
                    onClick = { viewModel.selectInfo(info) }
                )
            }

            // Incidents
            items(uiState.incidents) { group ->
                IncidentGroupCard(
                    group = group,
                    onClick = { /* ... */ }
                )
            }
        }
    }

    // Detail sheet
    if (uiState.showInfoDetail && uiState.selectedInfo != null) {
        InfoDetailScreen(
            info = uiState.selectedInfo!!,
            onDismiss = { viewModel.closeInfoDetail() }
        )
    }
}

Marqueurs carte

Fichier : utils/MarkerUtils.kt

fun createInfoMarker(
    mapView: MapView,
    info: InfoOfficielleEntity,
    onClick: () -> Unit
): Marker {
    val color = when (info.typeInfo) {
        TypeInfo.TRAVAUX -> Color(0xFFF59E0B)
        TypeInfo.EVENEMENT -> Color(0xFF8B5CF6)
        TypeInfo.ALERTE -> Color(0xFFEF4444)
        TypeInfo.ANNONCE -> Color(0xFF3B82F6)
    }

    val marker = Marker(mapView).apply {
        position = GeoPoint(info.latitude, info.longitude)
        title = info.titre
        snippet = info.description.take(100)
        icon = createColoredMarkerDrawable(mapView.context, color)
        setOnMarkerClickListener { _, _ ->
            onClick()
            true
        }
    }

    // Cercle de rayon
    val circle = Polygon(mapView).apply {
        points = Polygon.pointsAsCircle(
            GeoPoint(info.latitude, info.longitude),
            info.rayonMetres.toDouble()
        )
        fillPaint.color = color.copy(alpha = 0.2f).toArgb()
        outlinePaint.color = color.toArgb()
        outlinePaint.strokeWidth = 2f
    }

    mapView.overlays.add(circle)
    mapView.overlays.add(marker)

    return marker
}

Database Migration

Fichier : data/local/AppDatabase.kt

@Database(
    entities = [
        IncidentEntity::class,
        PhotoEntity::class,
        MairieEntity::class,
        InfoOfficielleEntity::class  // NEW
    ],
    version = 5,  // Increment version
    exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun incidentDao(): IncidentDao
    abstract fun photoDao(): PhotoDao
    abstract fun mairieDao(): MairieDao
    abstract fun infoOfficielleDao(): InfoOfficielleDao  // NEW

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "urbafix_database"
                )
                .fallbackToDestructiveMigration()  // Dev only
                .build()
                .also { INSTANCE = it }
            }
        }
    }
}

Converters

Fichier : data/local/Converters.kt

@TypeConverters
class Converters {

    @TypeConverter
    fun toTypeInfo(value: String): TypeInfo = TypeInfo.valueOf(value)

    @TypeConverter
    fun fromTypeInfo(type: TypeInfo): String = type.name

    @TypeConverter
    fun toStatutInfo(value: String): StatutInfo = StatutInfo.valueOf(value)

    @TypeConverter
    fun fromStatutInfo(statut: StatutInfo): String = statut.name
}

Dépendances

// build.gradle.kts
dependencies {
    // Room
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")

    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")

    // UnifiedPush
    implementation("com.github.UnifiedPush:android-connector:2.4.0")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}