etm-powersync-app/lib/screens/things_screen.dart
pakutz79 c19c9d1a98 feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256
- GoRouter + ShellRoute : navigation préservée entre onglets
- 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider,
  EnergySetupProvider, SchedulerProvider, TariffProvider
- Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline
- Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen
- DrawerMenuButton présent dans les 5 AppBars principaux
- Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles
- Compteur solaire : ajout smartmeter aux interfaces compatibles
- Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard
- Dépendances : go_router, shared_preferences, crypto, url_launcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:52:32 +01:00

410 lines
15 KiB
Dart

import 'dart:async';
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/app_theme.dart';
import '../main.dart' show DrawerMenuButton;
import 'category_overview_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// ThingsScreen — NIVEAU 1
// Grille 2 colonnes : une tuile par catégorie présente.
// Chaque tuile affiche l'icône/nom de catégorie et un carousel des things.
// ─────────────────────────────────────────────────────────────────────────────
class ThingsScreen extends StatelessWidget {
const ThingsScreen({super.key});
static const _orderedCats = [
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.cars, ThingCategory.hvac,
ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network,
ThingCategory.notifications, ThingCategory.weather, ThingCategory.media,
ThingCategory.other,
];
@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();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: _buildAppBar(service, things.length),
body: !service.isConnected
? _buildPlaceholder(
icon: Icons.cloud_off_rounded,
label: 'Non connecté à nymea',
)
: things.isEmpty && !service.thingsLoaded
? const Center(
child: CircularProgressIndicator(color: AppTheme.accentTeal))
: things.isEmpty
? _buildPlaceholder(
icon: Icons.devices_other_rounded,
label: 'Aucun appareil',
)
: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.98,
),
// Si les classes ne sont pas encore chargées → 1 tuile "Autres"
itemCount: cats.isEmpty ? 1 : cats.length,
itemBuilder: (context, i) {
final cat = cats.isEmpty ? ThingCategory.other : cats[i];
final catThings = grouped[cat] ?? things;
final info = categoryInfoMap[cat]!;
return _CategoryTile(
info: info,
things: catThings,
thingClasses: classes,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryOverviewScreen(
info: info,
things: catThings,
thingClasses: classes,
),
),
),
);
},
),
);
}
AppBar _buildAppBar(NymeaService service, int count) {
return AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 56,
title: Row(children: [
const Text('Things',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 20)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accentTeal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.accentTeal)),
),
]),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
onPressed: () => service.refresh(),
),
],
);
}
static Widget _buildPlaceholder(
{required IconData icon, required String label}) =>
Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(label,
style:
const TextStyle(color: AppTheme.textLight, fontSize: 16)),
]),
);
static NymeaThingClass? _classFor(
NymeaThing t, List<NymeaThingClass> classes) {
try {
return classes.firstWhere((c) => c.id == t.thingClassId);
} catch (_) {
return null;
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _CategoryTile — tuile d'une catégorie
//
// ┌────────────────────────────┐
// │ [icône catégorie] │ ← centrée, colorée
// │ NOM CATÉGORIE (CAPS) │
// │────────────────────────────│ ← séparateur couleur catégorie
// │ ● Thing name valeur │ ← carousel auto (3 s) si >1 thing
// └────────────────────────────┘
// ─────────────────────────────────────────────────────────────────────────────
class _CategoryTile extends StatefulWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
final VoidCallback onTap;
const _CategoryTile({
required this.info,
required this.things,
required this.thingClasses,
required this.onTap,
});
@override
State<_CategoryTile> createState() => _CategoryTileState();
}
class _CategoryTileState extends State<_CategoryTile> {
int _idx = 0;
Timer? _timer;
@override
void initState() {
super.initState();
if (widget.things.length > 1) {
_timer = Timer.periodic(const Duration(seconds: 3), (_) {
if (mounted) {
setState(
() => _idx = (_idx + 1) % widget.things.length);
}
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
NymeaThingClass? _cls(NymeaThing t) {
try {
return widget.thingClasses.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;
}
String? _primaryValue(NymeaThing t, NymeaThingClass? cls) {
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
@override
Widget build(BuildContext context) {
// Clamp _idx au cas où la liste shrink entre deux ticks du timer
final safeIdx = _idx.clamp(0, widget.things.length - 1);
final current = widget.things[safeIdx];
final cls = _cls(current);
final online = _isOnline(current, cls);
final value = _primaryValue(current, cls);
final info = widget.info;
return Material(
color: AppTheme.cardWhite,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Haut : icône + nom catégorie ───────────────────────────────
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(14),
),
child: Icon(info.icon, color: info.color, size: 28),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
info.label.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: AppTheme.textDark.withValues(alpha: 0.75),
letterSpacing: 0.7,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// ── Séparateur coloré ───────────────────────────────────────────
Container(
height: 1,
color: info.color.withValues(alpha: 0.25),
),
// ── Bas : carousel des things ───────────────────────────────────
SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: _CarouselRow(
key: ValueKey(_idx),
name: current.name,
value: value,
count: widget.things.length,
index: _idx,
isOnline: online,
color: info.color,
),
),
),
),
],
),
),
);
}
}
// ── Ligne carousel ────────────────────────────────────────────────────────────
class _CarouselRow extends StatelessWidget {
final String name;
final String? value;
final int count;
final int index;
final bool isOnline;
final Color color;
const _CarouselRow({
super.key,
required this.name,
required this.value,
required this.count,
required this.index,
required this.isOnline,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
// Pastille statut
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: isOnline ? color : Colors.grey[400],
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
// Nom du thing
Expanded(
child: Text(
name,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
// Valeur primaire — Flexible pour éviter l'overflow
Flexible(
fit: FlexFit.loose,
child: Text(
value ?? '',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isOnline ? color : AppTheme.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ThingCard — conservé pour compatibilité 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),
trailing:
value != null ? Text(value, style: TextStyle(color: categoryInfo.color)) : null,
onTap: onTap,
);
}
}