etm-powersync-app/lib/screens/things_screen.dart
Patrick Schurig ETM-Schurig d0a475a5d9 feat: refonte UI complète — design system EtmTokens + 4 écrans
Design system
- lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono)
- lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole
- app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens

Navigation & drawer
- DrawerMenuButton : logo vert gradient + ombre
- Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif
- DrawerPanel 320 px, restyled navy + gradient header + badges rôle

Dashboard (01_dashboard.html)
- Hero système : status pill + 3 métriques mono + illustration maison CustomPainter
- EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles)
  · gridPower > 0 = soutirage → flèche Grid→Home (amber)
  · gridPower < 0 = injection → flèche Home→Grid (bleu)
- EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar
- KPI 2×2 : spark bars, trend line, progress bar
- Consommateurs principaux + Décisions d'Héos (chips motifs)
- Prévisions placeholder explicite

Énergie
- KPI 2×2 avec icônes + fond soft + IBM Plex Mono
- Sélecteur période vert pill
- LineChart double axe : kW (gauche) / SOC % (droite, normalisé)
- BarChart bilan énergétique Wh (amber/bleu)
- Section Météo & prévision placeholder

Things
- Grille 2 col à hauteur intrinsèque (pas de childAspectRatio)
- Bandeau statut global (simulation / connecté / hors-ligne)
- _CategoryCard : header icon+label+count, séparateur coloré, liste tous items
- thing_category.dart : couleurs migrées vers EtmTokens

A/C — Climatisation / Chauffage
- Thermostats pièces EN HAUT : actives expandées, éteintes compactes
- Températures actuelle → cible ± avec EtmTokens.mono
- Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent)
- Chip "Chauffe au solaire en ce moment" (Héos)
- Sources pilotées par Héos EN BAS :
  · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto
  · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C
  · Climatiseur : pré-refroidissement anticipé + info solaire

Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage
Asset : assets/house.svg (illustration maison CustomPainter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:51:51 +02:00

538 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../main.dart' show DrawerMenuButton;
import 'category_overview_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// ThingsScreen — grille de catégories à hauteur intrinsèque
//
// Règle : pas de GridView avec childAspectRatio figé.
// Layout : Column de Row(Expanded × 2) → hauteur naturelle par catégorie.
// ─────────────────────────────────────────────────────────────────────────────
class ThingsScreen extends StatefulWidget {
const ThingsScreen({super.key});
@override
State<ThingsScreen> createState() => _ThingsScreenState();
}
class _ThingsScreenState extends State<ThingsScreen> {
static const _orderedCats = [
ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.hvac,
ThingCategory.energy, ThingCategory.cars,
ThingCategory.lighting, ThingCategory.sensors,
ThingCategory.network, ThingCategory.weather,
ThingCategory.media, ThingCategory.notifications,
ThingCategory.other,
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final service = context.read<NymeaService>();
if (!service.connected) service.startSimulation();
});
}
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final things = service.things.where((t) => t.id.isNotEmpty).toList();
final classes = service.thingClasses;
// Groupement par catégorie
final Map<ThingCategory, List<NymeaThing>> grouped = {};
for (final t in things) {
final cls = _classFor(t, classes);
final cat = cls?.category ?? ThingCategory.other;
grouped.putIfAbsent(cat, () => []).add(t);
}
final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList();
// Statut global
final allOnline = things.isNotEmpty &&
things.every((t) => _isOnline(t, _classFor(t, classes)));
final offlineCount = things.where((t) => !_isOnline(t, _classFor(t, classes))).length;
return Scaffold(
backgroundColor: EtmTokens.bg,
body: CustomScrollView(
slivers: [
// AppBar
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Row(
children: [
Text('Things',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
const SizedBox(width: 8),
if (things.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(8),
),
child: Text('${things.length}',
style: EtmTokens.mono(size: 12, weight: FontWeight.w700,
color: EtmTokens.greenDark)),
),
],
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.refresh_rounded,
color: EtmTokens.muted, size: 20),
onPressed: service.refresh,
),
),
],
),
// Body
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Bandeau statut global
if (service.isConnected || service.isSimulation) ...[
_StatusBanner(
allOnline: allOnline,
offlineCount: offlineCount,
simulation: service.isSimulation,
thingCount: things.length,
),
const SizedBox(height: 16),
],
// État de chargement
if (!service.isConnected && !service.isSimulation)
_Placeholder(
icon: Icons.cloud_off_rounded,
label: 'Non connecté à nymea',
)
else if (things.isEmpty && !service.thingsLoaded)
const Padding(
padding: EdgeInsets.only(top: 60),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2, color: EtmTokens.green),
),
)
else if (things.isEmpty)
_Placeholder(
icon: Icons.devices_other_rounded,
label: 'Aucun appareil configuré',
)
else
_CategoryGrid(
cats: cats,
grouped: grouped,
classes: classes,
),
]),
),
),
],
),
);
}
NymeaThingClass? _classFor(NymeaThing t, List<NymeaThingClass> cls) {
try { return cls.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
bool _isOnline(NymeaThing t, NymeaThingClass? cls) {
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
}
// ─────────────────────────── Bandeau statut ────────────────────────────────────
class _StatusBanner extends StatelessWidget {
final bool allOnline;
final int offlineCount;
final bool simulation;
final int thingCount;
const _StatusBanner({
required this.allOnline,
required this.offlineCount,
required this.simulation,
required this.thingCount,
});
@override
Widget build(BuildContext context) {
final Color dotColor;
final String text;
if (simulation) {
dotColor = EtmTokens.amber;
text = 'Mode simulation · $thingCount appareil(s) simulé(s)';
} else if (allOnline) {
dotColor = EtmTokens.green;
text = 'Tous les appareils connectés · dernière synchro à l\'instant';
} else if (offlineCount > 0) {
dotColor = EtmTokens.orange;
text = '$offlineCount appareil(s) hors ligne';
} else {
dotColor = EtmTokens.faint;
text = 'Aucun appareil connecté';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 10),
Expanded(
child: Text(text,
style: EtmTokens.sans(size: 13, color: EtmTokens.muted)),
),
],
),
);
}
}
// ─────────────────────────── Grille intrinsèque ────────────────────────────────
class _CategoryGrid extends StatelessWidget {
final List<ThingCategory> cats;
final Map<ThingCategory, List<NymeaThing>> grouped;
final List<NymeaThingClass> classes;
const _CategoryGrid({
required this.cats,
required this.grouped,
required this.classes,
});
@override
Widget build(BuildContext context) {
final rows = <Widget>[];
for (int i = 0; i < cats.length; i += 2) {
rows.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start, // hauteur intrinsèque
children: [
Expanded(
child: _CategoryCard(
cat: cats[i],
things: grouped[cats[i]] ?? [],
classes: classes,
onTap: () => _openCategory(context, cats[i], grouped[cats[i]] ?? [], classes),
),
),
const SizedBox(width: 14),
Expanded(
child: i + 1 < cats.length
? _CategoryCard(
cat: cats[i + 1],
things: grouped[cats[i + 1]] ?? [],
classes: classes,
onTap: () => _openCategory(
context, cats[i + 1], grouped[cats[i + 1]] ?? [], classes),
)
: const SizedBox(),
),
],
),
);
if (i + 2 < cats.length) rows.add(const SizedBox(height: 14));
}
return Column(children: rows);
}
void _openCategory(BuildContext context, ThingCategory cat,
List<NymeaThing> things, List<NymeaThingClass> classes) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryOverviewScreen(
info: categoryInfoMap[cat]!,
things: things,
thingClasses: classes,
),
),
);
}
}
// ─────────────────────────── Carte catégorie ───────────────────────────────────
class _CategoryCard extends StatelessWidget {
final ThingCategory cat;
final List<NymeaThing> things;
final List<NymeaThingClass> classes;
final VoidCallback onTap;
const _CategoryCard({
required this.cat,
required this.things,
required this.classes,
required this.onTap,
});
NymeaThingClass? _classFor(NymeaThing t) {
try { return classes.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
bool _isOnline(NymeaThing t) {
final cls = _classFor(t);
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
String? _value(NymeaThing t) {
final cls = _classFor(t);
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
@override
Widget build(BuildContext context) {
final info = categoryInfoMap[cat]!;
final count = things.length;
// Container en dehors de Material pour que l'ombre soit visible
return Container(
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(EtmTokens.radius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(EtmTokens.radius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// ── En-tête ────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
child: Row(
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(info.icon, color: info.color, size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.label.toUpperCase(),
style: EtmTokens.sans(
size: 9,
weight: FontWeight.w700,
color: EtmTokens.navy.withValues(alpha: 0.6),
letterSpacing: 0.6,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'$count appareil${count > 1 ? 's' : ''}',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
),
],
),
),
],
),
),
// ── Séparateur coloré ──────────────────────────────────────────
Container(height: 1, color: info.color.withValues(alpha: 0.20)),
// ── Liste des things ───────────────────────────────────────────
...things.map((t) => _ThingRow(
thing: t,
value: _value(t),
isOnline: _isOnline(t),
color: info.color,
isLast: t == things.last,
)),
],
),
),
),
); // closes InkWell
} // closes build
} // closes _CategoryCard
// ─────────────────────────── Ligne thing ───────────────────────────────────────
class _ThingRow extends StatelessWidget {
final NymeaThing thing;
final String? value;
final bool isOnline;
final Color color;
final bool isLast;
const _ThingRow({
required this.thing,
required this.value,
required this.isOnline,
required this.color,
required this.isLast,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 9, 14, 9),
decoration: BoxDecoration(
border: isLast
? null
: Border(bottom: BorderSide(color: EtmTokens.line)),
borderRadius: isLast
? const BorderRadius.vertical(bottom: Radius.circular(EtmTokens.radius))
: null,
),
child: Row(
children: [
Container(
width: 7, height: 7,
decoration: BoxDecoration(
color: isOnline ? color : EtmTokens.faint,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
thing.name,
style: EtmTokens.sans(size: 12, weight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
if (value != null)
Text(
value!,
style: EtmTokens.mono(
size: 12,
weight: FontWeight.w700,
color: isOnline ? color : EtmTokens.muted,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
else
Text(
isOnline ? 'OK' : 'Veille',
style: EtmTokens.sans(
size: 12,
color: isOnline ? color : EtmTokens.faint,
),
),
],
),
);
}
}
// ─────────────────────────── Placeholder ───────────────────────────────────────
class _Placeholder extends StatelessWidget {
final IconData icon;
final String label;
const _Placeholder({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 80),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: EtmTokens.faint),
const SizedBox(height: 14),
Text(label, style: EtmTokens.sans(size: 15, color: EtmTokens.muted)),
],
),
),
);
}
}
// ─────────────────────────── ThingCard (compat externe) ────────────────────────
class ThingCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo categoryInfo;
final VoidCallback onTap;
const ThingCard({
super.key,
required this.thing,
required this.thingClass,
required this.categoryInfo,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final primary = thingClass?.primaryStateType;
final value = primary?.formatValue(thing.stateValue(primary.id));
return ListTile(
leading: Icon(categoryInfo.icon, color: categoryInfo.color),
title: Text(thing.name, style: EtmTokens.sans(size: 14)),
trailing: value != null
? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color))
: null,
onTap: onTap,
);
}
}