diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 7bcdc13..f333ce1 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -73,6 +73,45 @@ class _EnergyScreenState extends State { /// C'est délibéré : les tuiles disent la journée, les graphes disent la fenêtre. DailyBalance? _bilan; + /// **Le premier point visible** de la fenêtre glissante, en index d'échantillon. + /// + /// La vue « Heures » porte 96 points (24 h au quart d'heure) : tout montrer d'un coup + /// écrase le détail, et il n'y a alors rien à faire glisser. On en montre donc la moitié + /// et on se déplace dedans. + /// + /// ⚠️ **On borne minX/maxX, on ne DÉCOUPE pas les données.** Découper renumérotrait les + /// index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les mauvaises + /// heures. Les séries restent entières, la fenêtre ne fait que cadrer. + double _fenetreDebut = 0; + + /// Nombre de points visibles à la fois. `null` = tout, donc rien à glisser. + int? get _fenetreTaille { + if (!_tabs[_tabIdx].showTime) return null; // seule la vue Heures glisse + return _data.length > 40 ? _data.length ~/ 2 : null; + } + + bool get _glissable => _fenetreTaille != null; + + /// Dernier point visible. Sans fenêtre, c'est tout le jeu de données. + double get _fenetreFin { + final taille = _fenetreTaille; + final dernier = (_data.length - 1).toDouble(); + if (taille == null) return dernier; + return (_fenetreDebut + taille).clamp(0, dernier).toDouble(); + } + + /// Déplace la fenêtre, **bornée aux données** : on ne glisse pas dans le vide. + /// + /// Le vide serait pire qu'une butée — il ferait croire qu'il n'y a rien à cet endroit, + /// alors qu'il n'y a simplement pas d'endroit. + void _glisser(double deltaPoints) { + final taille = _fenetreTaille; + if (taille == null) return; + final maxDebut = (_data.length - 1 - taille).toDouble(); + setState(() => + _fenetreDebut = (_fenetreDebut + deltaPoints).clamp(0, max(0, maxDebut))); + } + /// Vrai quand la box a livré ses Things ET qu'aucun ne porte de batterie. /// /// Les deux moitiés comptent : tant que les Things ne sont pas chargés, « pas de @@ -200,6 +239,9 @@ class _EnergyScreenState extends State { ]); if (!mounted) return; setState(() { + // La fenêtre repart du début à chaque lecture : la garder ferait cadrer une plage + // d'index qui ne désigne plus les mêmes heures. + _fenetreDebut = 0; _data = results[0] as List; _socData = results[1] as List; _conso = results[2] as ConsumerHistory; @@ -330,7 +372,7 @@ class _EnergyScreenState extends State { height: 220, loading: _loading, noData: _noData, - child: _buildLineChart(), + child: _glissant(_buildLineChart()), ), // La note d'absence de batterie va SOUS ce graphe-ci : c'est ici que // le SOC aurait été tracé, donc c'est ici qu'il manque. @@ -358,8 +400,16 @@ class _EnergyScreenState extends State { height: 220, loading: _loading, noData: _noData, - child: _buildConsumerChart(), + child: _glissant(_buildConsumerChart()), ), + // L'indice ne sort que si le geste existe : une aide permanente + // cesse d'être lue, et celle-ci serait fausse sur les autres vues. + if (_glissable) + const _NoteEcran( + icon: Icons.swipe_rounded, + texte: 'Glissez horizontalement sur un graphe pour vous déplacer ' + 'dans la journée. Les deux gardent le même cadrage.', + ), _notesConsommateurs(), const SizedBox(height: 14), @@ -377,7 +427,29 @@ class _EnergyScreenState extends State { // ── ① Line chart : puissances + SOC ──────────────────────────────────────── - Widget _buildLineChart() { + /// Rend un graphe **glissable à l'horizontale** quand la vue s'y prête. + /// + /// Le geste convertit des pixels en points d'échantillon : glisser d'une largeur d'écran + /// déplace d'une fenêtre entière, ce qui rend le mouvement indépendant de la densité des + /// données. Hors vue « Heures », le graphe est rendu tel quel — envelopper un widget + /// non glissable dans un détecteur de geste volerait le défilement vertical de la page + /// pour rien. + Widget _glissant(Widget graphe) { + if (!_glissable) return graphe; + final taille = _fenetreTaille!; + return LayoutBuilder( + builder: (context, c) => GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) { + final largeur = c.maxWidth <= 0 ? 1.0 : c.maxWidth; + _glisser(-d.delta.dx / largeur * taille); + }, + child: graphe, + ), + ); + } + + Widget _buildLineChart({bool axeGauche = true}) { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); final prodSpots = []; @@ -440,6 +512,8 @@ class _EnergyScreenState extends State { return LineChart( LineChartData( clipData: const FlClipData.all(), + minX: _fenetreDebut, + maxX: _fenetreFin, minY: 0, maxY: yMax, gridData: FlGridData( @@ -456,8 +530,8 @@ class _EnergyScreenState extends State { // Axe gauche : kW leftTitles: AxisTitles( sideTitles: SideTitles( - showTitles: true, - reservedSize: 46, + showTitles: axeGauche, + reservedSize: axeGauche ? 46 : 0, interval: yInterval, getTitlesWidget: (v, meta) { if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet @@ -632,7 +706,7 @@ class _EnergyScreenState extends State { return out; } - Widget _buildConsumerChart() { + Widget _buildConsumerChart({bool axeGauche = true}) { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); if (_conso.estVide) return const SizedBox.shrink(); @@ -705,6 +779,8 @@ class _EnergyScreenState extends State { LineChart( LineChartData( clipData: const FlClipData.all(), + minX: _fenetreDebut, + maxX: _fenetreFin, minY: resteNegatif ? -yMax * 0.25 : 0, maxY: yMax, gridData: FlGridData( @@ -720,8 +796,8 @@ class _EnergyScreenState extends State { rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( - showTitles: true, - reservedSize: 46, + showTitles: axeGauche, + reservedSize: axeGauche ? 46 : 0, interval: yMax / 4, getTitlesWidget: (v, meta) => v == meta.max ? const SizedBox.shrink() @@ -830,8 +906,16 @@ class _EnergyScreenState extends State { // ── Helpers ─────────────────────────────────────────────────────────────── - double _xInterval() => - _data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0; + /// L'espacement des étiquettes de l'axe du bas, calculé sur ce qui est **visible**. + /// + /// Le calculer sur le jeu entier donnerait six étiquettes réparties sur 24 h alors que la + /// fenêtre n'en montre que douze : on en verrait trois, mal placées. L'axe doit suivre le + /// cadrage, pas les données. + double _xInterval() { + final visibles = (_fenetreFin - _fenetreDebut).round(); + final n = visibles > 0 ? visibles : _data.length; + return n > 8 ? (n / 6).ceilToDouble() : 1.0; + } /// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne /// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une