L'accueil de la borne passe en bento, réglable depuis le manager

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) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-17 11:36:19 +02:00
parent 296b7a7519
commit 081d9a00d9
2 changed files with 152 additions and 89 deletions

View File

@ -13,6 +13,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.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:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -67,7 +68,6 @@ class _MainViewWidget extends State<MainViewWidget> {
AppConfigurationLink? appConfigurationLink; AppConfigurationLink? appConfigurationLink;
ValueNotifier<DateTime?> currentHourDate = ValueNotifier<DateTime?>((DateTime.now().toUtc()).add(Duration(hours: 1))); ValueNotifier<DateTime?> currentHourDate = ValueNotifier<DateTime?>((DateTime.now().toUtc()).add(Duration(hours: 1)));
late Color backgroundColor; late Color backgroundColor;
int rowCount = 4;
bool isImageBackground = false; bool isImageBackground = false;
@ -373,7 +373,13 @@ class _MainViewWidget extends State<MainViewWidget> {
if(isInit) { if(isInit) {
try { 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)); rawSectionsData = jsonDecode(jsonEncode(rawList));
List<SectionDTO> sectionList = jsonDecode(jsonEncode(rawSectionsData)).map((json) => SectionDTO.fromJson(json)).whereType<SectionDTO>().toList(); List<SectionDTO> sectionList = jsonDecode(jsonEncode(rawSectionsData)).map((json) => SectionDTO.fromJson(json)).whereType<SectionDTO>().toList();
sectionsLocal = sectionList; sectionsLocal = sectionList;
@ -397,101 +403,154 @@ class _MainViewWidget extends State<MainViewWidget> {
return sectionsLocal; 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) { getGridSections(AppContext appContext, Color textColor) {
TabletAppContext tabletAppContext = appContext.getContext() as TabletAppContext; TabletAppContext tabletAppContext = appContext.getContext() as TabletAppContext;
if(sectionsLocal != null) { if (sectionsLocal == null) {
var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
return Center( 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)), 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 = <int>[];
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 { getPlatformInfo() async {

View File

@ -81,6 +81,10 @@ dependencies:
#archive: ^3.6.1 #archive: ^3.6.1
manager_api_new: manager_api_new:
path: ../manager-app/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: dev_dependencies:
flutter_test: flutter_test: