K7 et K3 : SectionArticle et SectionEvent sur la borne
flutter build apk --debug vert, APK produit. La borne couvre désormais
12 des 13 types ; seul SectionParcours reste dehors, et définitivement —
un parcours guidé fait marcher le visiteur avec géodéclenchement.
Les deux écrans suivent la maquette paysage validée le 12/08 : ils sont
dessinés pour une dalle large et fixe, pas pour un téléphone debout.
K7 — Article. article_view.dart remplace le stub Text("TODO Article") et
le case est décommenté (l'import d'ArticleView était déjà en tête de
main_view, laissé par celui qui avait écrit le TODO). Rail média à 38 %
avec image, vignettes à plat et audio docké ; colonne de lecture à 62 %
plafonnée à 720 px, parce qu'étirer le texte sur 1280 px le rend
illisible. isContentTop, qui voulait dire « texte au-dessus » en portrait,
devient le côté : à true, texte à gauche et rail à droite.
Trois états incomplets, tous possibles en configuration. Le rail n'existe
que s'il a quelque chose à ancrer : sans audio le dock tombe, sans photo
le rail disparaît et l'audio passe en tête de la colonne, sans rien la
colonne se centre.
L'audio est porté par langue (audioIds est une List<TranslationDTO>) :
pas d'entrée pour la langue choisie, pas de lecteur, sans repli sur une
autre langue. TranslationHelper.get renvoyant "" quand la langue manque,
la règle tombe juste.
K3 — Événement. event_view.dart : bande héros à 26 % au lieu des 52 % de
mymuseum (debout devant une borne, personne ne défile pour découvrir
qu'il y a un programme dessous), programme à gauche avec bloc « en
cours », carte flutter_map vive à droite. Le détail d'un bloc s'ouvre
sous la carte et non en showModalBottomSheet : ce dernier est une réponse
à l'étroitesse, or ici il y a la place à côté.
Quatre choses que le code a dictées :
- La barre de pied de la maquette n'appartient pas à ces écrans.
section_page_detail la dessine déjà pour les treize types, avec les
clés back/menu selon isFromMenu. La dessiner aurait affiché deux
boutons retour.
- Un seul constructeur de marqueurs au lieu des quatre de mymuseum.
MapAnnotationDTO et MapAnnotation portent des champs rigoureusement
identiques mais sont deux types Dart distincts. Normalisé par une
classe interne à deux fabriques — L5 appliqué au front, et il porte
d'autant plus que la convention [lng, lat] est l'endroit exact où une
divergence ne lève aucune erreur.
- L'audio d'article se résout dans contents avant l'API. ContentDTO
porte son resource complet (idiome de marker_view), donc l'audio est
trouvé sans appel réseau quand il est embarqué, donc hors ligne aussi.
resourceGetDetail n'est qu'un repli ; mymuseum appelle l'API
systématiquement en ligne.
- Nouvelle clé i18n event.live dans les 10 langues. Sans les 10,
getFromLocale renvoie "" et la pastille rendrait une boîte vide.
ATTENTION : PL, CN, UK et AR sont de ma main et demandent une
relecture humaine.
Le nouveau code emploie withValues plutôt que withOpacity, déprécié —
le repo est en pleine migration (13 contre 9).
flutter analyze sur Screens/Article, Screens/Event et Helpers : zéro
issue dans les fichiers touchés. Les 2 restantes sont dans MQTTHelper,
préexistantes.
Non prouvé : le rendu. Aucun des deux écrans n'a été vu à l'œil, et un
build vert ne dit rien d'une mise en page. Aucun SectionEvent n'existe
en base, le type étant né avec Postgres v3 — c'est le §19.13 cas E qui
en créera un.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f582dd8a7
commit
7c29b4bb76
@ -16,7 +16,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Voir les réponses",
|
"quiz.seeResponses": "Voir les réponses",
|
||||||
"quiz.restart": "Recommencer",
|
"quiz.restart": "Recommencer",
|
||||||
"map.search": "Rechercher",
|
"map.search": "Rechercher",
|
||||||
"back": "Retour"
|
"back": "Retour",
|
||||||
|
"event.live": "En cours"
|
||||||
}),
|
}),
|
||||||
Translation(language: "EN", data: {
|
Translation(language: "EN", data: {
|
||||||
"weather.hourly": "Hourly",
|
"weather.hourly": "Hourly",
|
||||||
@ -33,7 +34,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "See the answers",
|
"quiz.seeResponses": "See the answers",
|
||||||
"quiz.restart": "Restart",
|
"quiz.restart": "Restart",
|
||||||
"map.search": "Search",
|
"map.search": "Search",
|
||||||
"back": "Back"
|
"back": "Back",
|
||||||
|
"event.live": "Now on"
|
||||||
}),
|
}),
|
||||||
Translation(language: "DE", data: {
|
Translation(language: "DE", data: {
|
||||||
"weather.hourly": "Nächste Stunden",
|
"weather.hourly": "Nächste Stunden",
|
||||||
@ -50,7 +52,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Sehen Sie sich die Antworten an",
|
"quiz.seeResponses": "Sehen Sie sich die Antworten an",
|
||||||
"quiz.restart": "Neu starten",
|
"quiz.restart": "Neu starten",
|
||||||
"map.search": "Suchen",
|
"map.search": "Suchen",
|
||||||
"back": "Zurück"
|
"back": "Zurück",
|
||||||
|
"event.live": "Läuft"
|
||||||
}),
|
}),
|
||||||
Translation(language: "NL", data: {
|
Translation(language: "NL", data: {
|
||||||
"weather.hourly": "Volgende uren",
|
"weather.hourly": "Volgende uren",
|
||||||
@ -67,7 +70,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Zie de antwoorden",
|
"quiz.seeResponses": "Zie de antwoorden",
|
||||||
"quiz.restart": "Herstarten",
|
"quiz.restart": "Herstarten",
|
||||||
"map.search": "Zoeken",
|
"map.search": "Zoeken",
|
||||||
"back": "Rug"
|
"back": "Rug",
|
||||||
|
"event.live": "Nu bezig"
|
||||||
}),
|
}),
|
||||||
Translation(language: "IT", data: {
|
Translation(language: "IT", data: {
|
||||||
"weather.hourly": "Le prossime ore",
|
"weather.hourly": "Le prossime ore",
|
||||||
@ -84,7 +88,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Vedi le risposte",
|
"quiz.seeResponses": "Vedi le risposte",
|
||||||
"quiz.restart": "Ricomincia",
|
"quiz.restart": "Ricomincia",
|
||||||
"map.search": "Cerca",
|
"map.search": "Cerca",
|
||||||
"back": "Indietro"
|
"back": "Indietro",
|
||||||
|
"event.live": "In corso"
|
||||||
}),
|
}),
|
||||||
Translation(language: "ES", data: {
|
Translation(language: "ES", data: {
|
||||||
"weather.hourly": "Próximas horas",
|
"weather.hourly": "Próximas horas",
|
||||||
@ -101,7 +106,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Ver las respuestas",
|
"quiz.seeResponses": "Ver las respuestas",
|
||||||
"quiz.restart": "Reanudar",
|
"quiz.restart": "Reanudar",
|
||||||
"map.search": "Buscar",
|
"map.search": "Buscar",
|
||||||
"back": "Atrás"
|
"back": "Atrás",
|
||||||
|
"event.live": "En curso"
|
||||||
}),
|
}),
|
||||||
Translation(language: "PL", data: {
|
Translation(language: "PL", data: {
|
||||||
"weather.hourly": "Następne godziny",
|
"weather.hourly": "Następne godziny",
|
||||||
@ -118,7 +124,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Zobacz odpowiedzi",
|
"quiz.seeResponses": "Zobacz odpowiedzi",
|
||||||
"quiz.restart": "Uruchom ponownie",
|
"quiz.restart": "Uruchom ponownie",
|
||||||
"map.search": "Szukaj",
|
"map.search": "Szukaj",
|
||||||
"back": "Z powrotem"
|
"back": "Z powrotem",
|
||||||
|
"event.live": "Trwa"
|
||||||
}),
|
}),
|
||||||
Translation(language: "CN", data: {
|
Translation(language: "CN", data: {
|
||||||
"weather.hourly": "接下来的几个小时",
|
"weather.hourly": "接下来的几个小时",
|
||||||
@ -135,7 +142,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "查看答案",
|
"quiz.seeResponses": "查看答案",
|
||||||
"quiz.restart": "重新开始",
|
"quiz.restart": "重新开始",
|
||||||
"map.search": "搜索",
|
"map.search": "搜索",
|
||||||
"back": "后退"
|
"back": "后退",
|
||||||
|
"event.live": "进行中"
|
||||||
}),
|
}),
|
||||||
Translation(language: "UK", data: {
|
Translation(language: "UK", data: {
|
||||||
"weather.hourly": "Наступні години",
|
"weather.hourly": "Наступні години",
|
||||||
@ -152,7 +160,8 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "Подивіться відповіді",
|
"quiz.seeResponses": "Подивіться відповіді",
|
||||||
"quiz.restart": "Перезапустіть",
|
"quiz.restart": "Перезапустіть",
|
||||||
"map.search": "Шукати",
|
"map.search": "Шукати",
|
||||||
"back": "Назад"
|
"back": "Назад",
|
||||||
|
"event.live": "Зараз"
|
||||||
}),
|
}),
|
||||||
Translation(language: "AR", data: {
|
Translation(language: "AR", data: {
|
||||||
"weather.hourly": "الساعات القادمة",
|
"weather.hourly": "الساعات القادمة",
|
||||||
@ -169,6 +178,7 @@ List<Translation> translations = [
|
|||||||
"quiz.seeResponses": "انظر الإجابات",
|
"quiz.seeResponses": "انظر الإجابات",
|
||||||
"quiz.restart": "إعادة تشغيل",
|
"quiz.restart": "إعادة تشغيل",
|
||||||
"map.search": "بحث",
|
"map.search": "بحث",
|
||||||
"back": "خلف"
|
"back": "خلف",
|
||||||
|
"event.live": "جارٍ الآن"
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@ -1,47 +1,227 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
//import 'dart:html';
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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:path_provider/path_provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tablet_app/Components/loading_common.dart';
|
import 'package:tablet_app/Components/cached_custom_resource.dart';
|
||||||
|
import 'package:tablet_app/Helpers/ImageCustomProvider.dart';
|
||||||
|
import 'package:tablet_app/Helpers/translationHelper.dart';
|
||||||
|
import 'package:tablet_app/Models/tabletContext.dart';
|
||||||
|
import 'package:tablet_app/app_context.dart';
|
||||||
|
import 'package:tablet_app/constants.dart';
|
||||||
|
|
||||||
|
/// Largeur de lecture. Une borne fait 1280 px de large : étirer le texte sur
|
||||||
|
/// toute la dalle le rend illisible, l'œil perd la ligne au retour.
|
||||||
|
const double kArticleMeasure = 720.0;
|
||||||
|
|
||||||
|
const double kMediaRailFlex = 38.0;
|
||||||
|
const double kArticleTextFlex = 62.0;
|
||||||
|
|
||||||
class ArticleView extends StatefulWidget {
|
class ArticleView extends StatefulWidget {
|
||||||
final SectionDTO? section;
|
final ArticleDTO section;
|
||||||
ArticleView({this.section});
|
|
||||||
|
const ArticleView({Key? key, required this.section}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_ArticleView createState() => _ArticleView();
|
State<ArticleView> createState() => _ArticleViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArticleView extends State<ArticleView> {
|
class _ArticleViewState extends State<ArticleView> {
|
||||||
ArticleDTO articleDTO = ArticleDTO();
|
String _audioId = "";
|
||||||
|
ResourceDTO? _audioResource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
/*print(widget.section!.data);
|
|
||||||
agendaDTO = AgendaDTO.fromJson(jsonDecode(widget.section!.data!))!;
|
|
||||||
print(agendaDTO);*/
|
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAudio());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
/// L'audio est porté par langue : `audioIds` est une List<TranslationDTO>.
|
||||||
void dispose() {
|
/// Pas d'entrée pour la langue choisie = pas de lecteur, sans repli sur une
|
||||||
//_webView = null;
|
/// autre langue — servir l'audio français à un néerlandophone est pire que
|
||||||
super.dispose();
|
/// le silence.
|
||||||
|
Future<void> _resolveAudio() async {
|
||||||
|
final tabletAppContext =
|
||||||
|
Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
|
||||||
|
final audioId = TranslationHelper.get(widget.section.audioIds, tabletAppContext);
|
||||||
|
if (audioId.isEmpty) return;
|
||||||
|
|
||||||
|
final embedded = widget.section.contents
|
||||||
|
?.where((content) => content.resourceId == audioId && content.resource != null)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
if (embedded != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_audioId = audioId;
|
||||||
|
_audioResource = embedded.resource;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resource = await tabletAppContext.clientAPI!.resourceApi!.resourceGetDetail(audioId);
|
||||||
|
if (resource != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_audioId = audioId;
|
||||||
|
_audioResource = resource;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("ERROR _resolveAudio ArticleView");
|
||||||
|
print(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<ContentDTO> _medias() {
|
||||||
|
final contents = widget.section.contents
|
||||||
|
?.where((content) => content.resource != null && content.resourceId != _audioId)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
contents.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return new Center(
|
final appContext = Provider.of<AppContext>(context);
|
||||||
child: Text("TODO Article"),
|
final tabletAppContext = appContext.getContext() as TabletAppContext;
|
||||||
|
final medias = _medias();
|
||||||
|
|
||||||
|
if (medias.isEmpty) {
|
||||||
|
return _buildTextOnly(appContext, tabletAppContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rail = Expanded(
|
||||||
|
flex: kMediaRailFlex.toInt(),
|
||||||
|
child: _buildMediaRail(appContext, tabletAppContext, medias),
|
||||||
|
);
|
||||||
|
final text = Expanded(
|
||||||
|
flex: kArticleTextFlex.toInt(),
|
||||||
|
child: _buildTextColumn(tabletAppContext, showAudio: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
// isContentTop veut dire « le texte avant le média ». En portrait c'était
|
||||||
|
// au-dessus ; en paysage il n'y a plus de dessus, le drapeau devient le côté.
|
||||||
|
final contentFirst = widget.section.isContentTop ?? false;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: contentFirst ? [text, rail] : [rail, text],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} //_webView
|
|
||||||
|
Widget _buildTextOnly(AppContext appContext, TabletAppContext tabletAppContext) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: kArticleMeasure),
|
||||||
|
child: _buildTextColumn(tabletAppContext, showAudio: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMediaRail(
|
||||||
|
AppContext appContext,
|
||||||
|
TabletAppContext tabletAppContext,
|
||||||
|
List<ContentDTO> medias,
|
||||||
|
) {
|
||||||
|
final main = medias.first;
|
||||||
|
final thumbs = medias.skip(1).toList();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
child: CachedCustomResource(
|
||||||
|
resourceDTO: main.resource!,
|
||||||
|
isAuto: false,
|
||||||
|
webView: false,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (thumbs.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12.0),
|
||||||
|
SizedBox(
|
||||||
|
height: 90.0,
|
||||||
|
child: Row(
|
||||||
|
children: thumbs
|
||||||
|
.map((content) => Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6.0),
|
||||||
|
child: Image(
|
||||||
|
image: ImageCustomProvider.getImageProvider(
|
||||||
|
appContext,
|
||||||
|
content.resourceId,
|
||||||
|
content.resource!.url ?? "",
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_audioResource != null) ...[
|
||||||
|
const SizedBox(height: 12.0),
|
||||||
|
_buildAudio(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextColumn(TabletAppContext tabletAppContext, {required bool showAudio}) {
|
||||||
|
final title = TranslationHelper.get(widget.section.title, tabletAppContext);
|
||||||
|
final content = TranslationHelper.get(widget.section.content, tabletAppContext);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 32.0),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: kArticleMeasure),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (title.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: kSectionTitleDetailSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: kMainGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showAudio && _audioResource != null) ...[
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
_buildAudio(),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
HtmlWidget(
|
||||||
|
content,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: kSectionDescriptionDetailSize,
|
||||||
|
color: kSecondGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudio() {
|
||||||
|
return CachedCustomResource(
|
||||||
|
resourceDTO: _audioResource!,
|
||||||
|
isAuto: widget.section.isReadAudioAuto ?? false,
|
||||||
|
webView: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
465
lib/Screens/Event/event_view.dart
Normal file
465
lib/Screens/Event/event_view.dart
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart' as ll;
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:tablet_app/Helpers/ImageCustomProvider.dart';
|
||||||
|
import 'package:tablet_app/Helpers/translationHelper.dart';
|
||||||
|
import 'package:tablet_app/Models/tabletContext.dart';
|
||||||
|
import 'package:tablet_app/app_context.dart';
|
||||||
|
import 'package:tablet_app/constants.dart';
|
||||||
|
|
||||||
|
/// Bande héros : 26 % de la hauteur. Sur mobile elle en prend 52 % et le
|
||||||
|
/// parallaxe récompense le défilement ; debout devant une borne, personne ne
|
||||||
|
/// défile pour découvrir qu'il y a un programme dessous.
|
||||||
|
const double kEventHeroRatio = 0.26;
|
||||||
|
|
||||||
|
const int kEventProgrammeFlex = 44;
|
||||||
|
const int kEventMapFlex = 56;
|
||||||
|
|
||||||
|
/// Couleur du bloc en cours et de ses annotations. Sémantique, distincte de
|
||||||
|
/// l'accent : c'est ce qui permet de relier une ligne du programme à un point
|
||||||
|
/// de la carte sans légende.
|
||||||
|
const Color kEventLiveColor = Color(0xFFC4622D);
|
||||||
|
|
||||||
|
/// Les coordonnées portées par `geometry.coordinates` sont en **[lng, lat]**
|
||||||
|
/// (GeoJSON). Celles de `GuidedStep` sont en [lat, lng] — les deux ordres
|
||||||
|
/// coexistent en production et une confusion ne lève aucune erreur, elle
|
||||||
|
/// déplace les points. Ne pas réutiliser un helper venu d'un autre contexte.
|
||||||
|
class _Annotation {
|
||||||
|
final GeometryType? geometryType;
|
||||||
|
final Object? coordinates;
|
||||||
|
final String? polyColor;
|
||||||
|
|
||||||
|
_Annotation(this.geometryType, this.coordinates, this.polyColor);
|
||||||
|
|
||||||
|
static _Annotation fromDto(MapAnnotationDTO a) =>
|
||||||
|
_Annotation(a.geometryType, a.geometry?.coordinates, a.polyColor);
|
||||||
|
|
||||||
|
static _Annotation fromBlock(MapAnnotation a) =>
|
||||||
|
_Annotation(a.geometryType, a.geometry?.coordinates, a.polyColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventView extends StatefulWidget {
|
||||||
|
final SectionEventDTO section;
|
||||||
|
|
||||||
|
const EventView({Key? key, required this.section}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventView> createState() => _EventViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventViewState extends State<EventView> {
|
||||||
|
ProgrammeBlock? _selectedBlock;
|
||||||
|
|
||||||
|
ProgrammeBlock? get _activeBlock {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return widget.section.programme
|
||||||
|
?.where((block) =>
|
||||||
|
block.startTime != null &&
|
||||||
|
block.endTime != null &&
|
||||||
|
!now.isBefore(block.startTime!) &&
|
||||||
|
!now.isAfter(block.endTime!))
|
||||||
|
.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _primaryColor(TabletAppContext tabletAppContext) {
|
||||||
|
try {
|
||||||
|
return Color(int.parse(
|
||||||
|
tabletAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0],
|
||||||
|
radix: 16));
|
||||||
|
} catch (_) {
|
||||||
|
return kTestSecondColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime? date) {
|
||||||
|
if (date == null) return "";
|
||||||
|
return "${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) =>
|
||||||
|
"${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}";
|
||||||
|
|
||||||
|
/// Les dates sont numériques, donc lisibles dans toutes les langues sans
|
||||||
|
/// traduction. C'est ce qui porte l'information « avant / pendant / après »
|
||||||
|
/// quand l'événement n'a pas lieu aujourd'hui.
|
||||||
|
String _formatDateRange() {
|
||||||
|
final start = widget.section.startDate;
|
||||||
|
final end = widget.section.endDate;
|
||||||
|
if (start == null && end == null) return "";
|
||||||
|
if (start == null) return _formatDate(end!);
|
||||||
|
if (end == null) return _formatDate(start);
|
||||||
|
return "${_formatDate(start)} → ${_formatDate(end)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final appContext = Provider.of<AppContext>(context);
|
||||||
|
final tabletAppContext = appContext.getContext() as TabletAppContext;
|
||||||
|
final primaryColor = _primaryColor(tabletAppContext);
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: size.height * kEventHeroRatio,
|
||||||
|
child: _buildHero(appContext, tabletAppContext, primaryColor),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: kEventProgrammeFlex,
|
||||||
|
child: _buildProgramme(tabletAppContext, primaryColor),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: kEventMapFlex,
|
||||||
|
child: _buildMapColumn(tabletAppContext, primaryColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHero(
|
||||||
|
AppContext appContext,
|
||||||
|
TabletAppContext tabletAppContext,
|
||||||
|
Color primaryColor,
|
||||||
|
) {
|
||||||
|
final title = TranslationHelper.get(widget.section.title, tabletAppContext);
|
||||||
|
final dateRange = _formatDateRange();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.section.imageSource != null)
|
||||||
|
Image(
|
||||||
|
image: ImageCustomProvider.getImageProvider(
|
||||||
|
appContext,
|
||||||
|
widget.section.imageId,
|
||||||
|
widget.section.imageSource!,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
primaryColor,
|
||||||
|
Color.lerp(primaryColor, Colors.white, 0.45) ?? primaryColor,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black54],
|
||||||
|
stops: [0.4, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 24.0,
|
||||||
|
right: 24.0,
|
||||||
|
bottom: 18.0,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: kTitleSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (dateRange.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(left: 16.0),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 6.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(20.0),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
dateRange,
|
||||||
|
style: TextStyle(
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: 16.0,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgramme(TabletAppContext tabletAppContext, Color primaryColor) {
|
||||||
|
final blocks = widget.section.programme ?? [];
|
||||||
|
if (blocks.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final active = _activeBlock;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
itemCount: blocks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final block = blocks[index];
|
||||||
|
return _buildBlock(
|
||||||
|
tabletAppContext,
|
||||||
|
primaryColor,
|
||||||
|
block,
|
||||||
|
isActive: block == active,
|
||||||
|
isSelected: block == _selectedBlock,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBlock(
|
||||||
|
TabletAppContext tabletAppContext,
|
||||||
|
Color primaryColor,
|
||||||
|
ProgrammeBlock block, {
|
||||||
|
required bool isActive,
|
||||||
|
required bool isSelected,
|
||||||
|
}) {
|
||||||
|
final title = TranslationHelper.get(block.title, tabletAppContext);
|
||||||
|
final start = _formatTime(block.startTime);
|
||||||
|
final end = _formatTime(block.endTime);
|
||||||
|
final liveLabel = TranslationHelper.getFromLocale("event.live", tabletAppContext);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => setState(() => _selectedBlock = isSelected ? null : block),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10.0),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 14.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? kEventLiveColor.withValues(alpha: 0.12) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
border: Border.all(
|
||||||
|
color: isActive
|
||||||
|
? kEventLiveColor
|
||||||
|
: (isSelected ? primaryColor : kBackgroundGrey.withValues(alpha: 0.5)),
|
||||||
|
width: isSelected && !isActive ? 2.0 : 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 62.0,
|
||||||
|
child: Text(
|
||||||
|
start,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.0,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isActive ? kEventLiveColor : kSecondGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.0,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : FontWeight.w500,
|
||||||
|
color: kMainGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (start.isNotEmpty && end.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
"$start – $end",
|
||||||
|
style: const TextStyle(fontSize: 14.0, color: kSecondGrey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isActive && liveLabel.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: kEventLiveColor,
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
liveLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12.0,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMapColumn(TabletAppContext tabletAppContext, Color primaryColor) {
|
||||||
|
final detail = _selectedBlock;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildMap(primaryColor)),
|
||||||
|
if (detail != null) _buildBlockDetail(tabletAppContext, primaryColor, detail),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMap(Color primaryColor) {
|
||||||
|
final latitude = double.tryParse(widget.section.latitude ?? "");
|
||||||
|
final longitude = double.tryParse(widget.section.longitude ?? "");
|
||||||
|
final center = latitude != null && longitude != null
|
||||||
|
? ll.LatLng(latitude, longitude)
|
||||||
|
: const ll.LatLng(50.465503, 4.865105);
|
||||||
|
|
||||||
|
final global = (widget.section.globalMapAnnotations ?? []).map(_Annotation.fromDto).toList();
|
||||||
|
final blockAnnotations =
|
||||||
|
(_selectedBlock ?? _activeBlock)?.mapAnnotations?.map(_Annotation.fromBlock).toList() ?? [];
|
||||||
|
|
||||||
|
return FlutterMap(
|
||||||
|
options: MapOptions(initialCenter: center, initialZoom: 14.0),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}",
|
||||||
|
userAgentPackageName: "be.unov.tabletapp",
|
||||||
|
),
|
||||||
|
PolylineLayer(polylines: _polylines(global, primaryColor)),
|
||||||
|
MarkerLayer(markers: _markers(global, primaryColor)),
|
||||||
|
PolylineLayer(polylines: _polylines(blockAnnotations, kEventLiveColor)),
|
||||||
|
MarkerLayer(markers: _markers(blockAnnotations, kEventLiveColor)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Marker> _markers(List<_Annotation> annotations, Color color) {
|
||||||
|
final markers = <Marker>[];
|
||||||
|
for (final annotation in annotations) {
|
||||||
|
if (annotation.geometryType?.value != 0) continue;
|
||||||
|
final coordinates = annotation.coordinates;
|
||||||
|
if (coordinates is! List || coordinates.length < 2) continue;
|
||||||
|
markers.add(Marker(
|
||||||
|
point: ll.LatLng(
|
||||||
|
(coordinates[1] as num).toDouble(),
|
||||||
|
(coordinates[0] as num).toDouble(),
|
||||||
|
),
|
||||||
|
width: 34.0,
|
||||||
|
height: 34.0,
|
||||||
|
child: Icon(Icons.place, color: color, size: 34.0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Polyline> _polylines(List<_Annotation> annotations, Color fallback) {
|
||||||
|
final polylines = <Polyline>[];
|
||||||
|
for (final annotation in annotations) {
|
||||||
|
if (annotation.geometryType?.value != 1) continue;
|
||||||
|
final coordinates = annotation.coordinates;
|
||||||
|
if (coordinates is! List) continue;
|
||||||
|
|
||||||
|
final points = <ll.LatLng>[];
|
||||||
|
for (final point in coordinates) {
|
||||||
|
if (point is List && point.length >= 2) {
|
||||||
|
points.add(ll.LatLng(
|
||||||
|
(point[1] as num).toDouble(),
|
||||||
|
(point[0] as num).toDouble(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (points.length < 2) continue;
|
||||||
|
|
||||||
|
polylines.add(Polyline(
|
||||||
|
points: points,
|
||||||
|
color: _polyColor(annotation.polyColor, fallback),
|
||||||
|
strokeWidth: 4.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return polylines;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _polyColor(String? hex, Color fallback) {
|
||||||
|
if (hex == null) return fallback;
|
||||||
|
final value = int.tryParse("FF${hex.replaceAll('#', '')}", radix: 16);
|
||||||
|
return value != null ? Color(value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sur mobile ce détail s'ouvre en `showModalBottomSheet` : une réponse à
|
||||||
|
/// l'étroitesse. Sur une borne il y a la place à côté, donc il s'affiche sous
|
||||||
|
/// la carte sans masquer ce qu'on regardait.
|
||||||
|
Widget _buildBlockDetail(
|
||||||
|
TabletAppContext tabletAppContext,
|
||||||
|
Color primaryColor,
|
||||||
|
ProgrammeBlock block,
|
||||||
|
) {
|
||||||
|
final title = TranslationHelper.get(block.title, tabletAppContext);
|
||||||
|
final description = TranslationHelper.get(block.description, tabletAppContext);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 220.0),
|
||||||
|
padding: const EdgeInsets.all(18.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: kBackgroundLight,
|
||||||
|
border: Border(top: BorderSide(color: kBackgroundGrey.withValues(alpha: 0.5))),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20.0,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: kMainGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: kSecondGrey),
|
||||||
|
onPressed: () => setState(() => _selectedBlock = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"${_formatTime(block.startTime)} – ${_formatTime(block.endTime)}",
|
||||||
|
style: TextStyle(fontSize: 15.0, color: primaryColor, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
if (description.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10.0),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: const TextStyle(fontSize: 16.0, color: kSecondGrey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ import 'package:tablet_app/Helpers/MQTTHelper.dart';
|
|||||||
import 'package:tablet_app/Helpers/translationHelper.dart';
|
import 'package:tablet_app/Helpers/translationHelper.dart';
|
||||||
import 'package:tablet_app/Screens/Agenda/agenda_view.dart';
|
import 'package:tablet_app/Screens/Agenda/agenda_view.dart';
|
||||||
import 'package:tablet_app/Screens/Article/article_view.dart';
|
import 'package:tablet_app/Screens/Article/article_view.dart';
|
||||||
|
import 'package:tablet_app/Screens/Event/event_view.dart';
|
||||||
import 'package:tablet_app/Screens/Configuration/config_view.dart';
|
import 'package:tablet_app/Screens/Configuration/config_view.dart';
|
||||||
import 'package:tablet_app/Screens/MainView/section_page_detail.dart';
|
import 'package:tablet_app/Screens/MainView/section_page_detail.dart';
|
||||||
import 'package:tablet_app/Screens/Weather/weather_view.dart';
|
import 'package:tablet_app/Screens/Weather/weather_view.dart';
|
||||||
@ -650,9 +651,12 @@ Widget getContent(TabletAppContext tabletAppContext, SectionDTO sectionSelected,
|
|||||||
case SectionType.Agenda :
|
case SectionType.Agenda :
|
||||||
AgendaDTO agendaDTO = AgendaDTO.fromJson(rawSectionData)!;
|
AgendaDTO agendaDTO = AgendaDTO.fromJson(rawSectionData)!;
|
||||||
return AgendaView(section: agendaDTO);
|
return AgendaView(section: agendaDTO);
|
||||||
/*case SectionType.article : // TODO
|
case SectionType.Article :
|
||||||
elementToShow = ArticleView(section: sectionSelected);
|
ArticleDTO articleDTO = ArticleDTO.fromJson(rawSectionData)!;
|
||||||
break;*/
|
return ArticleView(section: articleDTO);
|
||||||
|
case SectionType.Event :
|
||||||
|
SectionEventDTO eventDTO = SectionEventDTO.fromJson(rawSectionData)!;
|
||||||
|
return EventView(section: eventDTO);
|
||||||
case SectionType.Weather :
|
case SectionType.Weather :
|
||||||
WeatherDTO weatherDTO = WeatherDTO.fromJson(rawSectionData)!;
|
WeatherDTO weatherDTO = WeatherDTO.fromJson(rawSectionData)!;
|
||||||
return WeatherView(section: weatherDTO);
|
return WeatherView(section: weatherDTO);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user