import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:manager_api_new/api.dart'; import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart'; import 'package:manager_app/Screens/Kiosk_devices/change_device_info_modal.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; /// Flotte de casques. Comme pour le kiosk, la grille liste les liens de /// configuration de l'application VR — un lien existe avant qu'un casque ne s'y /// soit appairé, et l'appairage se fait depuis le casque avec le code PIN. class VrDevicesTab extends StatefulWidget { final ApplicationInstanceDTO applicationInstanceDTO; const VrDevicesTab({super.key, required this.applicationInstanceDTO}); @override State createState() => _VrDevicesTabState(); } class _VrDevicesTabState extends State { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final appContext = Provider.of(context); final ManagerAppContext managerAppContext = appContext.getContext(); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (managerAppContext.pinCode != null) Padding( padding: const EdgeInsets.only(bottom: kSpace5), child: Text( l.pinCode(managerAppContext.pinCode.toString()), style: const TextStyle(fontSize: 25.0, fontWeight: FontWeight.w300), ), ), Padding( padding: const EdgeInsets.only(bottom: kSpace3), child: Text(l.headsets), ), FutureBuilder( future: getAppConfigurationLink(appContext, widget.applicationInstanceDTO), builder: (context, AsyncSnapshot snapshot) { List? links = snapshot.data; if (snapshot.connectionState != ConnectionState.done) { return const SizedBox(height: 160, child: Center(child: CommonLoader())); } if (links == null) return Text(l.noData); return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 260, childAspectRatio: 1.25, mainAxisSpacing: 10, crossAxisSpacing: 10, ), itemCount: links.length, itemBuilder: (context, index) => _HeadsetCard(link: links[index]), ); }, ), ], ), ); } } class _HeadsetCard extends StatefulWidget { final AppConfigurationLinkDTO link; const _HeadsetCard({required this.link}); @override State<_HeadsetCard> createState() => _HeadsetCardState(); } class _HeadsetCardState extends State<_HeadsetCard> { bool _hovered = false; /// Batterie, version et dernier vu ne sont pas dans `DeviceDTO` : ils ne /// viennent que du détail, un appel par casque. Acceptable sur une flotte de /// bornes, qui se compte en unités. Future? _detail; @override void initState() { super.initState(); final device = widget.link.device; if (device?.id != null) { final ctx = Provider.of(context, listen: false).getContext() as ManagerAppContext; _detail = ctx.clientAPI!.deviceApi!.deviceGetDetail(device!.id!); } } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final device = widget.link.device; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedScale( scale: _hovered ? 1.03 : 1.0, duration: const Duration(milliseconds: 150), child: Container( decoration: BoxDecoration( color: kTextLightColor, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: kSecond, spreadRadius: _hovered ? 1 : 0.5, blurRadius: _hovered ? 10 : 5, offset: const Offset(0, 2), ), ], ), padding: const EdgeInsets.all(12), child: device == null ? _unpaired(l) : _paired(l, device), ), ), ); } Widget _unpaired(AppLocalizations l) { final title = widget.link.configuration?.label ?? ''; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.visibility_outlined, color: kBodyTextColor.withValues(alpha: 0.5), size: 34), const SizedBox(height: 10), Text( l.noHeadsetPaired, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: kBodyTextColor), ), if (title.isNotEmpty) ...[ const SizedBox(height: 4), Text( title, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: kBodyTextColor.withValues(alpha: 0.6)), ), ], ], ); } Widget _paired(AppLocalizations l, DeviceDTO device) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( device.name ?? device.identifier ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), ), Container( width: 12, height: 12, decoration: BoxDecoration( color: device.connected == true ? Colors.green : kError, borderRadius: BorderRadius.circular(25.0), ), ), ], ), const SizedBox(height: kSpace2), Text( device.configuration ?? l.noConfiguration, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13), ), const Spacer(), FutureBuilder( future: _detail, builder: (context, AsyncSnapshot snapshot) { final detail = snapshot.data; if (detail == null) return const SizedBox(); final silent = _silentFor(detail.lastSeen); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (silent != null) _silentBanner(l, silent), if (detail.batteryLevel != null) _infoLine(Icons.battery_std_outlined, '${l.headsetBattery} ${detail.batteryLevel}'), if (detail.appVersion != null) _infoLine(Icons.info_outline, '${l.headsetAppVersion} ${detail.appVersion}'), _infoLine( Icons.schedule, detail.lastSeen == null ? l.headsetNeverSeen : l.headsetLastSeen(DateFormat('dd/MM/yyyy HH:mm').format(detail.lastSeen!)), ), ], ); }, ), Align( alignment: Alignment.centerRight, child: IconButton( icon: const Icon(Icons.edit), color: kPrimaryColor, onPressed: () => _edit(l, device), ), ), ], ); } /// Un casque qui bat toutes les 3 minutes et qu'on n'a plus entendu depuis /// **une heure** n'est pas « en veille » : il est éteint, hors wifi, ou son app /// est tombée. Le seuil est volontairement large — vingt battements manqués — /// pour qu'une alerte veuille dire quelque chose. static const _silenceThreshold = Duration(hours: 1); Duration? _silentFor(DateTime? lastSeen) { if (lastSeen == null) return null; final silence = DateTime.now().difference(lastSeen.toLocal()); return silence >= _silenceThreshold ? silence : null; } /// La pastille verte dit « connecté » parce que le serveur l'a écrit au dernier /// battement — elle ne sait pas qu'il date d'hier. C'est ce mensonge-là que cette /// bannière corrige. Widget _silentBanner(AppLocalizations l, Duration silence) { final label = silence.inHours >= 24 ? l.headsetSilentDays(silence.inDays.toString()) : l.headsetSilentHours(silence.inHours.toString()); return Container( margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric(horizontal: kSpace2, vertical: 2), decoration: BoxDecoration( color: kError.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.warning_amber_rounded, size: 13, color: kError), const SizedBox(width: 4), Flexible( child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: kError, fontWeight: FontWeight.w600)), ), ], ), ); } Widget _infoLine(IconData icon, String label) { return Padding( padding: const EdgeInsets.only(bottom: 2), child: Row( children: [ Icon(icon, size: 13, color: kInk3), const SizedBox(width: 4), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: kInk3), ), ), ], ), ); } void _edit(AppLocalizations l, DeviceDTO device) { final appContext = Provider.of(context, listen: false); showChangeInfo( l.updateHeadsetBtn, device, widget.link, (DeviceDTO outputDevice, AppConfigurationLinkDTO outputLink) async { final ctx = appContext.getContext() as ManagerAppContext; await ctx.clientAPI!.deviceApi!.deviceUpdateMainInfos(outputDevice); outputLink.configurationId = outputDevice.configurationId; await ctx.clientAPI!.applicationInstanceApi!.applicationInstanceUpdateApplicationLink(outputLink); if (!mounted) return; showNotification(kSuccess, kWhite, l.appUpdatedSuccess, context, null); setState(() {}); }, 1, context, appContext, ); } }