- Kotlin 98.6%
- Python 1%
- Shell 0.4%
- 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) |
||
|---|---|---|
| app | ||
| gradle | ||
| scripts | ||
| .gitignore | ||
| build.gradle.kts | ||
| CLAUDE.md | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
| SPECS.md | ||
☁️ Météo
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
- Stack technique
- Sources de données
- Architecture
- Prérequis
- Build & tests
- Structure du projet
- Décisions de conception
- Roadmap
- Licence
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).
NetworkModule— 5 instances Retrofit (une par base URL : Open-Meteo forecast, Open-Meteo air-quality, Open-Meteo geocoding, BigDataCloud reverse, RainViewer) partageant un seulOkHttpClient+ uneJsonpermissive (ignoreUnknownKeys,coerceInputValues).DatabaseModule— Room + DAOs.RepositoryModule— bindings*Impl→ interfaces.TimeProvider,StringProvideret les mappers sont injectés (déterminisme et localisation testables). Les messages d'erreur transitent parR.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 surgetWeatherviaflatMapLatestquand 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
compileSdk35 ; - Un appareil physique (min. API 26) branché en USB pour les builds
installDebuget 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
.envgitignoré à 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 (0–99), 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 25–35 °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, voirgenerateWeatherIcons).