Compare commits
2 Commits
9f582dd8a7
...
6efd67611f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efd67611f | ||
|
|
7c29b4bb76 |
@ -16,7 +16,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Voir les réponses",
|
||||
"quiz.restart": "Recommencer",
|
||||
"map.search": "Rechercher",
|
||||
"back": "Retour"
|
||||
"back": "Retour",
|
||||
"event.live": "En cours"
|
||||
}),
|
||||
Translation(language: "EN", data: {
|
||||
"weather.hourly": "Hourly",
|
||||
@ -33,7 +34,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "See the answers",
|
||||
"quiz.restart": "Restart",
|
||||
"map.search": "Search",
|
||||
"back": "Back"
|
||||
"back": "Back",
|
||||
"event.live": "Now on"
|
||||
}),
|
||||
Translation(language: "DE", data: {
|
||||
"weather.hourly": "Nächste Stunden",
|
||||
@ -50,7 +52,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Sehen Sie sich die Antworten an",
|
||||
"quiz.restart": "Neu starten",
|
||||
"map.search": "Suchen",
|
||||
"back": "Zurück"
|
||||
"back": "Zurück",
|
||||
"event.live": "Läuft"
|
||||
}),
|
||||
Translation(language: "NL", data: {
|
||||
"weather.hourly": "Volgende uren",
|
||||
@ -67,7 +70,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Zie de antwoorden",
|
||||
"quiz.restart": "Herstarten",
|
||||
"map.search": "Zoeken",
|
||||
"back": "Rug"
|
||||
"back": "Rug",
|
||||
"event.live": "Nu bezig"
|
||||
}),
|
||||
Translation(language: "IT", data: {
|
||||
"weather.hourly": "Le prossime ore",
|
||||
@ -84,7 +88,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Vedi le risposte",
|
||||
"quiz.restart": "Ricomincia",
|
||||
"map.search": "Cerca",
|
||||
"back": "Indietro"
|
||||
"back": "Indietro",
|
||||
"event.live": "In corso"
|
||||
}),
|
||||
Translation(language: "ES", data: {
|
||||
"weather.hourly": "Próximas horas",
|
||||
@ -101,7 +106,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Ver las respuestas",
|
||||
"quiz.restart": "Reanudar",
|
||||
"map.search": "Buscar",
|
||||
"back": "Atrás"
|
||||
"back": "Atrás",
|
||||
"event.live": "En curso"
|
||||
}),
|
||||
Translation(language: "PL", data: {
|
||||
"weather.hourly": "Następne godziny",
|
||||
@ -118,7 +124,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Zobacz odpowiedzi",
|
||||
"quiz.restart": "Uruchom ponownie",
|
||||
"map.search": "Szukaj",
|
||||
"back": "Z powrotem"
|
||||
"back": "Z powrotem",
|
||||
"event.live": "Trwa"
|
||||
}),
|
||||
Translation(language: "CN", data: {
|
||||
"weather.hourly": "接下来的几个小时",
|
||||
@ -135,7 +142,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "查看答案",
|
||||
"quiz.restart": "重新开始",
|
||||
"map.search": "搜索",
|
||||
"back": "后退"
|
||||
"back": "后退",
|
||||
"event.live": "进行中"
|
||||
}),
|
||||
Translation(language: "UK", data: {
|
||||
"weather.hourly": "Наступні години",
|
||||
@ -152,7 +160,8 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "Подивіться відповіді",
|
||||
"quiz.restart": "Перезапустіть",
|
||||
"map.search": "Шукати",
|
||||
"back": "Назад"
|
||||
"back": "Назад",
|
||||
"event.live": "Зараз"
|
||||
}),
|
||||
Translation(language: "AR", data: {
|
||||
"weather.hourly": "الساعات القادمة",
|
||||
@ -169,6 +178,7 @@ List<Translation> translations = [
|
||||
"quiz.seeResponses": "انظر الإجابات",
|
||||
"quiz.restart": "إعادة تشغيل",
|
||||
"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_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:tablet_app/Components/loading_common.dart';
|
||||
import 'package:provider/provider.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 {
|
||||
final SectionDTO? section;
|
||||
ArticleView({this.section});
|
||||
final ArticleDTO section;
|
||||
|
||||
const ArticleView({Key? key, required this.section}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ArticleView createState() => _ArticleView();
|
||||
State<ArticleView> createState() => _ArticleViewState();
|
||||
}
|
||||
|
||||
class _ArticleView extends State<ArticleView> {
|
||||
ArticleDTO articleDTO = ArticleDTO();
|
||||
class _ArticleViewState extends State<ArticleView> {
|
||||
String _audioId = "";
|
||||
ResourceDTO? _audioResource;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
/*print(widget.section!.data);
|
||||
agendaDTO = AgendaDTO.fromJson(jsonDecode(widget.section!.data!))!;
|
||||
print(agendaDTO);*/
|
||||
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAudio());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//_webView = null;
|
||||
super.dispose();
|
||||
/// 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 — servir l'audio français à un néerlandophone est pire que
|
||||
/// 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
|
||||
Widget build(BuildContext context) {
|
||||
return new Center(
|
||||
child: Text("TODO Article"),
|
||||
final appContext = Provider.of<AppContext>(context);
|
||||
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/Screens/Agenda/agenda_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/MainView/section_page_detail.dart';
|
||||
import 'package:tablet_app/Screens/Weather/weather_view.dart';
|
||||
@ -650,9 +651,12 @@ Widget getContent(TabletAppContext tabletAppContext, SectionDTO sectionSelected,
|
||||
case SectionType.Agenda :
|
||||
AgendaDTO agendaDTO = AgendaDTO.fromJson(rawSectionData)!;
|
||||
return AgendaView(section: agendaDTO);
|
||||
/*case SectionType.article : // TODO
|
||||
elementToShow = ArticleView(section: sectionSelected);
|
||||
break;*/
|
||||
case SectionType.Article :
|
||||
ArticleDTO articleDTO = ArticleDTO.fromJson(rawSectionData)!;
|
||||
return ArticleView(section: articleDTO);
|
||||
case SectionType.Event :
|
||||
SectionEventDTO eventDTO = SectionEventDTO.fromJson(rawSectionData)!;
|
||||
return EventView(section: eventDTO);
|
||||
case SectionType.Weather :
|
||||
WeatherDTO weatherDTO = WeatherDTO.fromJson(rawSectionData)!;
|
||||
return WeatherView(section: weatherDTO);
|
||||
|
||||
@ -49,11 +49,13 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
||||
bool init = false;
|
||||
DateTime? _sectionOpenTime;
|
||||
StatisticsService? _statisticsService;
|
||||
Timer? _idleTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sectionOpenTime = DateTime.now();
|
||||
_restartIdleTimer();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final tabletAppContext = Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
|
||||
_statisticsService = tabletAppContext.statisticsService;
|
||||
@ -64,8 +66,25 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Un visiteur part sans fermer l'écran ; le suivant trouve le contenu du
|
||||
/// précédent. Le minuteur vit ici parce que cet écran enveloppe les treize
|
||||
/// types : l'écrire dans les écrans eux-mêmes le ferait écrire treize fois.
|
||||
void _restartIdleTimer() {
|
||||
_idleTimer?.cancel();
|
||||
_idleTimer = Timer(kKioskIdleTimeout, _returnHome);
|
||||
}
|
||||
|
||||
void _returnHome() {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_idleTimer?.cancel();
|
||||
// Le retour automatique passe par ce dispose, donc il émet un sectionLeave
|
||||
// avec sa durée réelle. Sans lui, une consultation abandonnée resterait
|
||||
// ouverte jusqu'au visiteur suivant et fausserait la statistique.
|
||||
final duration = _sectionOpenTime != null
|
||||
? DateTime.now().difference(_sectionOpenTime!).inSeconds
|
||||
: null;
|
||||
@ -85,7 +104,14 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SingleChildScrollView(
|
||||
// Listener plutôt que GestureDetector : il observe les pointeurs sans
|
||||
// entrer en concurrence avec les gestes des écrans en dessous — une carte
|
||||
// qu'on fait glisser ou un quiz qu'on tape ne doivent rien perdre.
|
||||
body: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (_) => _restartIdleTimer(),
|
||||
onPointerMove: (_) => _restartIdleTimer(),
|
||||
child: SingleChildScrollView(
|
||||
child: Container(
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
@ -236,6 +262,7 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
/*floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
|
||||
floatingActionButton: Padding(
|
||||
|
||||
@ -17,6 +17,10 @@ const kBackgroundLight = Color(0xfff3f3f3);
|
||||
|
||||
const List<String> languages = ["FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK"]; // hmmmm depends on config..
|
||||
|
||||
/// Délai sans interaction au bout duquel la borne revient à l'accueil, pour que
|
||||
/// le visiteur suivant ne trouve pas le contenu du précédent.
|
||||
const Duration kKioskIdleTimeout = Duration(minutes: 5);
|
||||
|
||||
const kTitleSize = 32.0;
|
||||
const kDescriptionSize = 22.0;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user