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.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);
|
||||||
|
|||||||
@ -49,11 +49,13 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
|||||||
bool init = false;
|
bool init = false;
|
||||||
DateTime? _sectionOpenTime;
|
DateTime? _sectionOpenTime;
|
||||||
StatisticsService? _statisticsService;
|
StatisticsService? _statisticsService;
|
||||||
|
Timer? _idleTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_sectionOpenTime = DateTime.now();
|
_sectionOpenTime = DateTime.now();
|
||||||
|
_restartIdleTimer();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final tabletAppContext = Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
|
final tabletAppContext = Provider.of<AppContext>(context, listen: false).getContext() as TabletAppContext;
|
||||||
_statisticsService = tabletAppContext.statisticsService;
|
_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
|
@override
|
||||||
void dispose() {
|
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
|
final duration = _sectionOpenTime != null
|
||||||
? DateTime.now().difference(_sectionOpenTime!).inSeconds
|
? DateTime.now().difference(_sectionOpenTime!).inSeconds
|
||||||
: null;
|
: null;
|
||||||
@ -85,7 +104,14 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
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(
|
child: Container(
|
||||||
height: size.height,
|
height: size.height,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
@ -237,6 +263,7 @@ class _SectionPageDetailState extends State<SectionPageDetail> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
/*floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
|
/*floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
|
||||||
floatingActionButton: Padding(
|
floatingActionButton: Padding(
|
||||||
padding: const EdgeInsets.only(top: 10.0),
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
|
|||||||
@ -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..
|
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 kTitleSize = 32.0;
|
||||||
const kDescriptionSize = 22.0;
|
const kDescriptionSize = 22.0;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user