From 081d9a00d98d594160128bd67af50be0970b4e3e Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 17 Sep 2026 11:36:19 +0200 Subject: [PATCH] =?UTF-8?q?L'accueil=20de=20la=20borne=20passe=20en=20bent?= =?UTF-8?q?o,=20r=C3=A9glable=20depuis=20le=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Même grille à six colonnes que le menu du casque, même algorithme de placement que le mobile et l'aperçu du manager. La borne envoie son deviceId : elle reçoit son propre agencement, pas la configuration nue. La hauteur de cellule se déduit de la hauteur disponible et non de la largeur — une borne ne défile pas. L'orientation est concentrée en un point, pour que le portrait de la V2 soit un branchement et pas une réécriture. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Screens/MainView/main_view.dart | 237 +++++++++++++++++----------- pubspec.yaml | 4 + 2 files changed, 152 insertions(+), 89 deletions(-) diff --git a/lib/Screens/MainView/main_view.dart b/lib/Screens/MainView/main_view.dart index 9330aa2..4be597b 100644 --- a/lib/Screens/MainView/main_view.dart +++ b/lib/Screens/MainView/main_view.dart @@ -13,6 +13,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; +import 'package:myinfomate_layout/myinfomate_layout.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -67,7 +68,6 @@ class _MainViewWidget extends State { AppConfigurationLink? appConfigurationLink; ValueNotifier currentHourDate = ValueNotifier((DateTime.now().toUtc()).add(Duration(hours: 1))); late Color backgroundColor; - int rowCount = 4; bool isImageBackground = false; @@ -373,7 +373,13 @@ class _MainViewWidget extends State { if(isInit) { try { - final rawList = await tabletAppContext.clientAPI!.sectionApi!.sectionGetFromConfigurationDetail(tabletAppContext.configuration!.id!); + // Le deviceId désigne le lien de cette borne, donc son agencement : ordre, + // tailles bento et sections masquées pour le seul canal kiosk. Sans lui, le + // serveur rend la configuration nue et la borne perd sa mise en page. + final rawList = await tabletAppContext.clientAPI!.sectionApi!.sectionGetFromConfigurationDetail( + tabletAppContext.configuration!.id!, + deviceId: tabletAppContext.deviceId, + ); rawSectionsData = jsonDecode(jsonEncode(rawList)); List sectionList = jsonDecode(jsonEncode(rawSectionsData)).map((json) => SectionDTO.fromJson(json)).whereType().toList(); sectionsLocal = sectionList; @@ -397,101 +403,154 @@ class _MainViewWidget extends State { return sectionsLocal; } + /// Le nombre de colonnes de la grille bento. + /// + /// **6**, comme le menu du casque : c'est la même vue, sur un écran qui a la même + /// forme. Doit rester aligné sur `kKioskColumns` de `kiosk_screen.dart` côté + /// manager-app, sinon l'aperçu montre au client une grille que sa borne ne rend pas. + /// + /// **Point de décision d'orientation** (lot K9) : la borne est en paysage en V1, le + /// portrait est un objectif V2. Tout ce qui dépend de l'orientation passe par ici + /// plutôt que par un `if portrait` semé dans l'écran — la V2 devient un branchement, + /// pas une réécriture. + int get bentoColumns => 6; + + /// L'écran principal en bento : chaque section occupe colSpan × rowSpan cellules, + /// réglées par canal dans manager-app. Même placement dense que le mobile et l'aperçu + /// du manager (`myinfomate_layout`), que le web refait en CSS Grid et le casque en C#. + /// + /// ⚠️ La hauteur de cellule se déduit de la **hauteur disponible**, pas de la largeur : + /// une borne ne défile pas, la grille doit tenir dans l'écran. getGridSections(AppContext appContext, Color textColor) { TabletAppContext tabletAppContext = appContext.getContext() as TabletAppContext; - if(sectionsLocal != null) { - var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait; - + if (sectionsLocal == null) { return Center( - child: Container( - //color: Colors.greenAccent, - child: GridView.builder( - shrinkWrap: true, - gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: rowCount), - itemCount: sectionsLocal!.length, - itemBuilder: (BuildContext context, int index) { - return InkWell( - onTap: () { - setState(() { - //sectionSelected = sectionsLocal![index]; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return SectionPageDetail(configurationDTO: configurationDTO, sectionDTO: sectionsLocal![index], textColor: textColor, isImageBackground: isImageBackground, elementToShow: getContent(tabletAppContext, sectionsLocal![index], isImageBackground, rawSectionsData[index]), isFromMenu: false); - }, - ),// For pushAndRemoveUntil - ); - }); - }, - child: Container( - decoration: isImageBackground ? boxDecoration(appContext, sectionsLocal![index], false, rawSectionsData[index]) : null, - //color: Colors.yellow, - padding: EdgeInsets.all(isImageBackground ? 18 : 5), - margin: EdgeInsets.symmetric(vertical: isImageBackground ? 25 : 10, horizontal: 25), - child: isImageBackground ? Align( - alignment: Alignment.bottomRight, - child: FractionallySizedBox( - heightFactor: 0.5, - child: Container( - //color: Colors.green, - child: SizedBox( - width: double.infinity, - child: Align( - alignment: Alignment.centerRight, - child: HtmlWidget( - sectionsLocal![index].title!.firstWhere((translation) => translation.language == appContext.getContext().language).value ?? "", - customStylesBuilder: (element) { - return {'text-align': 'right', 'font-family': "Roboto"}; - }, - textStyle: TextStyle(fontSize: isPortrait ? 10 : 25),//calculateFontSize(constraints.maxWidth, constraints.maxHeight, kIsWeb ? kWebMenuTitleDetailSize : kMenuTitleDetailSize)), - ), - ), - ), - ) - ), - ) : Column( - children: [ - Expanded( - flex: 8, - child: Container( - decoration: BoxDecoration( - color: sectionsLocal![index].imageSource == null ? kBackgroundLight : null, // default color if no image - shape: BoxShape.rectangle, - image: sectionsLocal![index].imageSource != null || sectionsLocal![index].type == SectionType.Video ? new DecorationImage( - fit: BoxFit.cover, - image: ImageCustomProvider.getImageProvider(appContext, sectionsLocal![index].imageId, sectionsLocal![index].type == SectionType.Video ? getYoutubeThumbnailUrl(rawSectionsData[index]) : sectionsLocal![index].imageSource!), - ): null, - ), - ) - ), - Expanded( - flex: 2, - child: Center( - child: HtmlWidget( - sectionsLocal![index].title!.where((translation) => translation.language == appContext.getContext().language).firstOrNull?.value ?? "", - customStylesBuilder: (element) { - return {'text-align': 'center', 'font-family': "Roboto"}; - }, - textStyle: TextStyle(fontSize: isPortrait ? 10 : 25),//calculateFontSize(constraints.maxWidth, constraints.maxHeight, kIsWeb ? kWebMenuTitleDetailSize : kMenuTitleDetailSize)), - ), - ) - ) - ], - ) - ), - ); - } - ), - ), - ); - } else { - Center( child: Text("Aucune section à afficher", style: TextStyle(fontSize: kNoneInfoOrIncorrect)), ); } + var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait; + + // Les index des sections affichées. On garde l'index d'origine : `rawSectionsData` + // est apparié à `sectionsLocal` position par position, et une section masquée + // décalerait les deux listes l'une par rapport à l'autre. + final visibleIndexes = []; + for (var i = 0; i < sectionsLocal!.length; i++) { + if (sectionsLocal![i].isActive != false) visibleIndexes.add(i); + } + + final layout = bentoLayout( + visibleIndexes + .map((i) => BentoItem( + id: sectionsLocal![i].id ?? '$i', + colSpan: (sectionsLocal![i].gridColSpan ?? 1).clamp(1, bentoColumns), + rowSpan: (sectionsLocal![i].gridRowSpan ?? 1).clamp(1, 2), + )) + .toList(), + bentoColumns, + ); + + return LayoutBuilder( + builder: (context, constraints) { + const gap = 16.0; + final rows = layout.rowCount < 1 ? 1 : layout.rowCount; + final cellWidth = (constraints.maxWidth - (bentoColumns - 1) * gap) / bentoColumns; + final cellHeight = (constraints.maxHeight - (rows - 1) * gap) / rows; + + // Un placement par item, dans l'ordre des items : `placements[i]` désigne la + // section `visibleIndexes[i]`. + return Stack( + children: [ + for (var i = 0; i < layout.placements.length; i++) + Positioned( + left: layout.placements[i].col * (cellWidth + gap), + top: layout.placements[i].row * (cellHeight + gap), + width: layout.placements[i].colSpan * cellWidth + + (layout.placements[i].colSpan - 1) * gap, + height: layout.placements[i].rowSpan * cellHeight + + (layout.placements[i].rowSpan - 1) * gap, + child: sectionTile( + appContext, tabletAppContext, textColor, visibleIndexes[i], isPortrait), + ), + ], + ); + }, + ); + } + + Widget sectionTile(AppContext appContext, TabletAppContext tabletAppContext, + Color textColor, int index, bool isPortrait) { + return InkWell( + onTap: () { + setState(() { + //sectionSelected = sectionsLocal![index]; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return SectionPageDetail(configurationDTO: configurationDTO, sectionDTO: sectionsLocal![index], textColor: textColor, isImageBackground: isImageBackground, elementToShow: getContent(tabletAppContext, sectionsLocal![index], isImageBackground, rawSectionsData[index]), isFromMenu: false); + }, + ),// For pushAndRemoveUntil + ); + }); + }, + child: Container( + decoration: isImageBackground ? boxDecoration(appContext, sectionsLocal![index], false, rawSectionsData[index]) : null, + //color: Colors.yellow, + padding: EdgeInsets.all(isImageBackground ? 18 : 5), + child: isImageBackground ? Align( + alignment: Alignment.bottomRight, + child: FractionallySizedBox( + heightFactor: 0.5, + child: Container( + //color: Colors.green, + child: SizedBox( + width: double.infinity, + child: Align( + alignment: Alignment.centerRight, + child: HtmlWidget( + sectionsLocal![index].title!.firstWhere((translation) => translation.language == appContext.getContext().language).value ?? "", + customStylesBuilder: (element) { + return {'text-align': 'right', 'font-family': "Roboto"}; + }, + textStyle: TextStyle(fontSize: isPortrait ? 10 : 25),//calculateFontSize(constraints.maxWidth, constraints.maxHeight, kIsWeb ? kWebMenuTitleDetailSize : kMenuTitleDetailSize)), + ), + ), + ), + ) + ), + ) : Column( + children: [ + Expanded( + flex: 8, + child: Container( + decoration: BoxDecoration( + color: sectionsLocal![index].imageSource == null ? kBackgroundLight : null, // default color if no image + shape: BoxShape.rectangle, + image: sectionsLocal![index].imageSource != null || sectionsLocal![index].type == SectionType.Video ? new DecorationImage( + fit: BoxFit.cover, + image: ImageCustomProvider.getImageProvider(appContext, sectionsLocal![index].imageId, sectionsLocal![index].type == SectionType.Video ? getYoutubeThumbnailUrl(rawSectionsData[index]) : sectionsLocal![index].imageSource!), + ): null, + ), + ) + ), + Expanded( + flex: 2, + child: Center( + child: HtmlWidget( + sectionsLocal![index].title!.where((translation) => translation.language == appContext.getContext().language).firstOrNull?.value ?? "", + customStylesBuilder: (element) { + return {'text-align': 'center', 'font-family': "Roboto"}; + }, + textStyle: TextStyle(fontSize: isPortrait ? 10 : 25),//calculateFontSize(constraints.maxWidth, constraints.maxHeight, kIsWeb ? kWebMenuTitleDetailSize : kMenuTitleDetailSize)), + ), + ) + ) + ], + ) + ), + ); } getPlatformInfo() async { diff --git a/pubspec.yaml b/pubspec.yaml index d086e87..13f83c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,10 @@ dependencies: #archive: ^3.6.1 manager_api_new: path: ../manager-app/manager_api_new + # Le placement bento de l'écran principal, partagé avec manager-app (aperçu), + # mymuseum-visitapp et — porté en C# — le menu du casque. + myinfomate_layout: + path: ../manager-app/myinfomate_layout dev_dependencies: flutter_test: