No description
  • Kotlin 98.6%
  • Python 1%
  • Shell 0.4%
Find a file
Cyril Leblanc 1e31f6b8b9 docs: refresh outdated README to match the built app
- 14j -> 16j (carrousel + graphe Windy, 336 -> 384 colonnes) ; 4 -> 5 Retrofit
- add sections: carrousel quotidien 16j, fond de ciel reactif (degrade + effets
  ambiants + teinte chaleur), fusion multi-modeles (arome -> seamless -> best_match)
- build: ./gradlew test --tests -> :app:testDebugUnitTest --tests ; add assembleRelease
  + .env signing note ; appareil physique uniquement, jamais emulateur
- decisions/structure/roadmap refreshed (glassmorphism, icones animees, sky gradient,
  multi-model marqués livrés)
2026-07-04 19:20:29 +02:00
app build: sign release via gitignored .env (signingConfig) 2026-07-04 19:17:08 +02:00
gradle icons: icônes météo Meteocons (Lottie animé + VectorDrawables) 2026-07-01 19:55:54 +02:00
scripts feat(ui): manche a air 4 niveaux pour le vent ; rafales -> wind (fini tornado) 2026-07-02 20:43:54 +02:00
.gitignore build: sign release via gitignored .env (signingConfig) 2026-07-04 19:17:08 +02:00
build.gradle.kts feat(phase-0): scaffold projet Android (Compose + Hilt + Room + Retrofit + MapLibre + Vico) 2026-06-27 07:58:46 +02:00
CLAUDE.md fix(ui): stale banner, radar legend + now-tick, a11y and flagged bug fixes 2026-07-04 11:20:14 +02:00
gradle.properties feat(phase-0): scaffold projet Android (Compose + Hilt + Room + Retrofit + MapLibre + Vico) 2026-06-27 07:58:46 +02:00
gradlew feat(phase-0): scaffold projet Android (Compose + Hilt + Room + Retrofit + MapLibre + Vico) 2026-06-27 07:58:46 +02:00
gradlew.bat feat(phase-0): scaffold projet Android (Compose + Hilt + Room + Retrofit + MapLibre + Vico) 2026-06-27 07:58:46 +02:00
LICENSE docs: README complet et licence MIT 2026-06-29 19:03:14 +02:00
README.md docs: refresh outdated README to match the built app 2026-07-04 19:20:29 +02:00
settings.gradle.kts feat(phase-0): scaffold projet Android (Compose + Hilt + Room + Retrofit + MapLibre + Vico) 2026-06-27 07:58:46 +02:00
SPECS.md fix(ui): stale banner, radar legend + now-tick, a11y and flagged bug fixes 2026-07-04 11:20:14 +02:00

☁️ Météo

License: MIT Kotlin Android minSdk Compose JDK API

Application météo Android (Kotlin + Jetpack Compose + Material 3) — conditions courantes, prévisions horaires sur 16 jours, qualité de l'air, radar de précipitations animé (RainViewer) et un fond de ciel réactif qui épouse la météo, l'heure et la chaleur. Interface 100 % en français.

Conçue autour d'un principe : aucune clé API, aucune dépendance Google. Toutes les sources de données sont gratuites et publiques ; la géolocalisation repose sur l'API AOSP native (LocationManager), sans Google Play Services.

Robuste avant tout : offline-first, états d'erreur typés, pas de crash sur les cas limites (réseau coupé, cache périmé, frames radar manquantes).


Sommaire


Fonctionnalités

Conditions courantes

En-tête complet pour la ville affichée : température, ressenti, vent, rafales, indice UV, humidité, précipitations, enneigement et qualité de l'air (indice européen EAQI avec niveau qualitatif : Bon → Très mauvais).

Carrousel quotidien (16 jours)

Résumé scannable de chaque journée (icône, températures min/max, badge pluie) au-dessus du graphe. Un tap sur une journée bascule le graphe en vue détaillée (1 jour) centrée sur ce jour.

Graphe horaire « Windy » (16 jours)

Une grille multi-lignes inspirée de Windy.com, partageant un même axe de temps sur 384 colonnes (16 j × 24 h) :

Ligne Contenu
En-têtes Date de chaque journée
Codes météo Icône conditions (jour/nuit) par heure
Température Courbe lissée, dégradé chaud/froid
Précipitations Courbe remplie pluie + neige (mm)
Vent Bande d'intensité colorée (km/h)
Rafales Bande d'intensité colorée (km/h)
Direction du vent Flèches orientées
Axe du temps Libellés d'heure denses adaptatifs

Interactions :

  • 🤏 Pinch-to-zoom ancré au centre : l'heure affichée au milieu y reste pendant tout le geste (pas de saut ni de glissade). Du zoom 1 h à 16 jours.
  • 👆 Curseur / scrubber : un tap active une ligne curseur qui suit le doigt (glisser), avec edge-scroll aux bords et un overlay numérique (heure, météo, température, pluie, vent, rafales, direction) ancré du côté le plus dégagé.
  • 🌙 Bandes nocturnes, ☀️ bande d'ensoleillement (suivant le rayonnement), et une ligne « maintenant » traversant toutes les rangées.

Radar de précipitations animé (RainViewer)

Carte MapLibre avec fond raster et overlay de tuiles RainViewer :

  • ▶️ Lecture / pause, animation fluide (~350 ms par frame) ;
  • 🎚️ Scrubber temporel : navigation manuelle dans le passé et la prévision ;
  • 📍 Pin de position et recentrage ;
  • 🔄 Buffering paresseux : seule la frame « maintenant » est chargée à l'ouverture, le préchargement complet démarre à la lecture ou au scrubbing (minimise les requêtes de tuiles) ;
  • 🛡️ Gestion du cycle de vie MapView (pas de fuite mémoire) et repli discret (« Radar indisponible ») si le réseau ou les frames sont absents.

Fond de ciel réactif

Le fond plein écran épouse la météo et l'heure, derrière des cartes translucides :

  • 🌅 Dégradé de ciel continu (3 stops) : bleu jour, indigo nuit, teinté par la condition, avec une rampe douce (smoothstep ±30 min) au lever/coucher ;
  • ☁️🌧️❄️ Effets ambiants dessinés au-dessus du ciel : nuages dérivants (ciel nuageux), pluie (il pleut), neige (il neige), étoiles scintillantes (nuit dégagée) ;
  • 🌡️ Teinte « heat haze » : le ciel vire au doré quand il fait réellement chaud (≥ ~25 °C), automatiquement inhibée de nuit et par le froid.

Le tout est piloté par de la logique pure (SkyGradientMath, SkyEffectsMath), testable en JVM, et dessiné hors de la recomposition (zéro impact sur le défilement).

Recherche & géolocalisation

  • 🔍 Recherche de villes par nom, avec debounce 300 ms (forward geocoding).
  • 📡 Géolocalisation au premier lancement : fix GPS → reverse-geocoding → ajout de la ville courante comme défaut. Permissions demandées via l'API native rememberLauncherForActivityResult (Accompanist délibérément évité).
  • 🏙️ Multi-villes : ajout/sélection d'une ville par défaut ; l'écran Home se rebranche automatiquement (réactif).

Expérience robuste

  • ⬇️ Pull-to-refresh ;
  • 💾 Offline-first : cache Room de 10 min ; en cas d'échec réseau, les données périmées restent affichées avec un badge « Données anciennes » ;
  • 🎨 États typés exhaustifs : Loading (shimmer) / Error (retry) / Empty (CTA) / Content ;
  • 🌗 Thème Material 3 clair/sombre.

Stack technique

Domaine Choix
Langage Kotlin 2.1.0
UI Jetpack Compose + Material 3 (BOM 2025.01.01)
Build AGP + Kotlin DSL + Version Catalog (gradle/libs.versions.toml)
Réseau Retrofit 2.11 + OkHttp 4.12 + kotlinx.serialization
Persistance Room 2.6.1 (KSP) — cache offline-first
Carte MapLibre GL Android 11.8.3
DI Hilt 2.52
Async Coroutines + Flow / StateFlow
Cycle de vie ViewModel + Lifecycle + StateFlow
Navigation Navigation Compose
Refresh UI PullToRefreshBox (Material 3 ≥ 1.3)
Géoloc android.location.LocationManager (AOSP, pas de Google Play Services)
Tests JUnit4 + MockK + Turbine + Truth + kotlinx-coroutines-test

Build R8 en release (isMinifyEnabled = true) ; les sérialiseurs @Serializable sont conservés (voir app/proguard-rules.pro).

Cibles SDK

Valeur
applicationId / namespace dev.cyrleb.meteo
compileSdk / targetSdk 35
minSdk 26 (Android 8.0, ~97 % du parc)
versionName 0.1.0
Java JDK 21

Sources de données

Toutes gratuites et sans clé API.

Besoin Service Point d'accès
Météo (current/hourly/daily, 14 j) Open-Meteo https://api.open-meteo.com/v1/forecast
Qualité de l'air (EAQI) Open-Meteo Air Quality https://air-quality-api.open-meteo.com/v1/air-quality
Recherche de ville (forward) Open-Meteo Geocoding https://geocoding-api.open-meteo.com/v1/search
Coords → ville (reverse) BigDataCloud https://api.bigdatacloud.net/data/reverse-geocode-client (localityLanguage=fr)
Tuiles radar pluie RainViewer https://api.rainviewer.com/public/weather-maps.json

Pourquoi deux sources de geocoding ? Open-Meteo ne fait que le forward (nom → coords). Le reverse (coords → ville) au premier lancement GPS passe par BigDataCloud (gratuit, sans clé, en français).


Architecture

Clean architecture en couches strictes. La règle de dépendance est unidirectionnelle : ui → domain ← data.

  • domain/ — modèles métier purs (zéro dépendance Android / Retrofit / Room). Reste testable en JVM et réutilisable.
  • data/dto/ (miroir exact du JSON, @Serializable), api/ (interfaces Retrofit), local/ (Room), mapper/ (conversions DTO/Entity ↔ domain), repository/ (interface + *Impl).
  • ui/screen/<feature>/ (Screen + ViewModel + UiState), component/, radar/, theme/, navigation/, util/.

DI (Hilt)

WeatherApplication (@HiltAndroidApp) → MainActivity (@AndroidEntryPoint).

  • NetworkModule5 instances Retrofit (une par base URL : Open-Meteo forecast, Open-Meteo air-quality, Open-Meteo geocoding, BigDataCloud reverse, RainViewer) partageant un seul OkHttpClient + une Json permissive (ignoreUnknownKeys, coerceInputValues).
  • DatabaseModule — Room + DAOs.
  • RepositoryModule — bindings *Impl → interfaces.
  • TimeProvider, StringProvider et les mappers sont injectés (déterminisme et localisation testables). Les messages d'erreur transitent par R.string.*.

États réactifs

  • WeatherState (sealed) : Loading → Fresh / Stale → Error / Empty.
  • Écran Home réactif : HomeViewModel.contentFlow() observe la ville par défaut et se rebranche sur getWeather via flatMapLatest quand elle change.
  • Aucune exception ne remonte à l'UI — elle ne consomme que WeatherState.

Prérequis

  • JDK 21 (requis : sourceCompatibility/targetCompatibility/jvmTarget = 21) ;
  • Android SDK avec compileSdk 35 ;
  • Un appareil physique (min. API 26) branché en USB pour les builds installDebug et les tests instrumentés — jamais d'émulateur ;
  • Connexion Internet au runtime (les sources sont distantes ; aucune clé à fournir).

Build & tests

./gradlew assembleDebug        # build du APK debug
./gradlew installDebug         # build + installation sur l'appareil USB connecté

./gradlew :app:testDebugUnitTest                                                          # tous les tests unitaires (JVM)
./gradlew :app:testDebugUnitTest --tests "dev.cyrleb.meteo.data.repository.WeatherRepositoryImplTest"   # une classe
./gradlew :app:testDebugUnitTest --tests "*WmoCodeTest"                                                  # par motif

./gradlew assembleRelease       # APK release signé via .env (R8 minifié)
./gradlew connectedAndroidTest  # tests instrumentés (appareil physique USB — jamais d'émulateur)
./gradlew lint                  # analyse lint
./gradlew clean

⚠️ Appareil physique uniquement — jamais d'émulateur, jamais de capture d'écran. Toute installation ou vérification se fait sur un vrai téléphone branché en USB (adb devices) ; les vérifications visuelles sont faites par l'utilisateur sur l'appareil.

🔑 Build release : la signature est lue depuis un fichier .env gitignoré à la racine (METEO_KEYSTORE_FILE / METEO_KEYSTORE_PASSWORD / METEO_KEY_ALIAS / METEO_KEY_PASSWORD). Sans .env, le variant release n'est pas signé.

Stratégie de test

Les tests vivent à côté du code qu'ils couvrent (même package, app/src/test). Pile : JUnit4 + MockK + Turbine + Truth + kotlinx-coroutines-test ; Room via base en mémoire + room-testing. testOptions.unitTests.isReturnDefaultValues = true : les appels Android retournent des valeurs par défaut (pas d'exception) en JVM.

Priorité de couverture : domaine, mappers, repository (offline-first), ViewModels, ainsi que la logique pure extraite des composants (RadarTimeline, RadarBufferController, WindyChartMath, WindyCursorMath, SkyGradientMath, SkyEffectsMath, MultiModelForecastParser).


Structure du projet

app/src/main/java/dev/cyrleb/meteo/
├── WeatherApplication.kt          # @HiltAndroidApp + init MapLibre (once)
├── MainActivity.kt                # @AndroidEntryPoint, setContent
│
├── di/                            # Hilt : Network, Database, Repository modules
│   ├── NetworkModule.kt           # 4 Retrofit, 1 OkHttp, Json permissive
│   ├── DatabaseModule.kt
│   ├── RepositoryModule.kt        # bindings *Impl → interfaces
│   ├── StringProvider.kt          # R.string.* (localisation)
│   └── TimeProvider.kt            # temps injecté (déterminisme)
│
├── domain/                        # PUR (zéro dépendance Android)
│   ├── model/                     # WeatherSnapshot, CurrentConditions, HourPoint,
│   │                              #   DayPoint, LocationSummary, WeatherUnits,
│   │                              #   RadarFrame, AirQuality
│   ├── weather/                   # WmoCode (099), WeatherCondition
│   └── air/                       # AirQualityLevel (EAQI)
│
├── data/
│   ├── remote/
│   │   ├── dto/                   # OpenMeteo, AirQuality, Geocoding, RainViewer…
│   │   └── api/                   # interfaces Retrofit
│   ├── local/                     # Room : WeatherDatabase, entity/, dao/
│   ├── mapper/                    # WeatherMapper, LocationMapper, RainViewerMapper,
│   │                              #   AirQualityMapper, MultiModelForecastParser,
│   │                              #   CachedWeatherDto
│   └── repository/                # interfaces + *Impl + WeatherState
│
├── location/                      # UserLocationProvider (AOSP) + LocationResult
│
└── ui/
    ├── navigation/                # WeatherNavGraph, WeatherDestination
    ├── theme/                     # M3 : Color, Type, Shape, Theme (+ CHROME_CARD_ALPHA)
    ├── component/                 # HeroCard, CurrentWeatherHeader, MetricTile,
    │   ├── windy/                 #   graphe horaire « Windy » (éclaté en composants),
    │   │                          #   DailyForecastCarousel, RadarMapView,
    │   └── …                      #   WeatherSkyEffects (effets ambiants), LoadingShimmer…
    ├── radar/                     # RadarViewModel, RadarTimeline, RadarBufferController
    │                              #   (logique pure, testée hors device)
    ├── screen/
    │   ├── home/                  # HomeScreen + HomeViewModel + HomeUiState
    │   └── search/                # SearchScreen + SearchViewModel
    └── util/                      # UiState, TempColor, SkyGradientMath, SkyEffectsMath,
                                   #   DailySelection, DailyTempRange, DataAge

Décisions de conception

Offline-first. getWeather(location) émet un WeatherState : cache frais (< 10 min) servi sans réseau ; cache périmé émis en Stale le temps d'un essai réseau ; en cas d'échec avec cache déjà affiché, les données restent visibles (rien de bloquant). refresh() force le réseau (pull-to-refresh). Les constantes de TTL/max-age vivent sur le companion du repository et sont assertées en tests.

Cache météo stocké en blob JSON (WeatherCacheEntity.payloadJson) plutôt qu'en schéma relationnel — délibérément robuste aux évolutions du schéma Open-Meteo sans migration Room. Le snapshot est lu en bloc, jamais requêté champ par champ.

Gestion du temps Open-Meteo. Avec timezone=auto, les horodatages arrivent en heure locale sans offset. WeatherMapper les parse en LocalDateTime et les convertit en epoch absolu (ms) via le fuseau IANA de la réponse (repli UTC). Le marqueur « maintenant » reste cohérent quel que soit le fuseau de l'appareil.

Géolocalisation sans Google Play Services. UserLocationProvider enveloppe android.location.LocationManager. Le HomeViewModel n'auto-localise qu'au vrai premier lancement (aucune ville enregistrée), via les gardes-fous attemptedAutoLocate / permissionRequested pour éviter les boucles.

MapLibre initialisé exactement une fois dans WeatherApplication.onCreate() avant toute création de MapView, sous peine de MapLibreConfigurationException.

Radar : animation & buffering séparés pour la testabilité. La logique pure (RadarTimeline, RadarBufferController) est extraite du composable MapLibre dans des objets testables en JVM. L'animation est pilotée depuis le composable (LaunchedEffect).

Graphe « Windy » : calculs purs, composable passif. Toute la géométrie (WindyChartMath, WindyCursorMath) vit hors du composable — celui-ci ne câble que données + style, conformément à la règle « zéro logique métier dans un composable ».

États typés exhaustifs (sealed interface) plutôt que booléens épars : WeatherState, RadarUiState, WeatherContentState. Ajouter un cas force le compilateur à mettre à jour tous les when — aucun else/default sur ces états.

Fusion multi-modèles (priorité + repli). OpenMeteoApi.getForecast() renvoie le JSON brut et passe un paramètre models ordonné par priorité (meteofrance_arome_france_hd, meteofrance_seamless, best_match). MultiModelForecastParser (logique pure) résout chaque variable en construisant "${var}_${modelId}" puis coalescé par (variable, index) — la première valeur non-nulle gagne. Le bloc current cassé par le multi-modèle est reconstitué depuis l'horaire à l'index « maintenant ». La priorité vient de ForecastModelPreferences (injectable) → domain/ui inchangés.

Fond de ciel réactif, logique pure. SkyGradientMath calcule un dégradé 3 stops depuis la condition + l'heure + la température (heatFactor smoothstep sur 2535 °C, inhibée par dayFactor et par le froid) ; SkyEffectsMath produit les champs d'effets déterministes (LCG grainé). Les deux sont testables en JVM ; le composable ne fait que mettre à l'échelle et animer, et lit les phases uniquement dans le Canvas (invalidation de dessin seul — la Column défilante n'est jamais recomposée par l'animation).


Roadmap

Le MVP est livré. Quelques éléments déjà en place au-delà du périmètre initial :

  • Verre & transparence — cartes translucides (CHROME_CARD_ALPHA), voile teinté par la condition sur la hero card ;
  • Icônes météo animées — Lottie (hero) + VectorDrawables générés depuis les SVG statiques (scripts/svg2vd.py) ;
  • Fond de ciel réactif — dégradé condition/heure + effets ambiants + teinte chaleur ;
  • Fusion multi-modèles — AROME → seamless → best_match ;
  • Carrousel 16 jours tap-to-focus.

Extensions prévues (section « Hors MVP » de SPECS.md) :

  • Notifications matinales et alertes orage/pluie (WorkManager — dépendance déjà déclarée, DIs prêtes) ;
  • Widget d'accueil (Glance) ;
  • Écran détail heure par heure (réutilise HourlyForecast, inclut le vent) ;
  • Multi-villes étendu (liste favoris, swipe entre villes, cache complet) ;
  • Paramètres (unités °C/°F, langue) ;
  • Crash reporting & analytics (optionnel).

Licence

Distribué sous licence MIT — voir le fichier LICENSE.

Les données proviennent de services tiers gratuits (Open-Meteo, BigDataCloud, RainViewer) qui ne nécessitent pas de clé API ; cette licence s'applique au code de l'application uniquement.

Ressources tierces

  • Icônes météo — Meteocons par Bas Milius (licence MIT). L'icône « hero » utilise les Lottie animés (assets/meteocons/) ; les glyphes du graphe Windy utilisent des VectorDrawables générés depuis les SVG statiques (scripts/meteocons-svg/scripts/svg2vd.py, voir generateWeatherIcons).