759 lines
27 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_map_full_page.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart';
import 'package:mymuseum_visitapp/constants.dart';
class EventPage extends StatefulWidget {
const EventPage({
Key? key,
required this.section,
required this.visitAppContextIn,
}) : super(key: key);
final SectionEventDTO section;
final VisitAppContext visitAppContextIn;
@override
State<EventPage> createState() => _EventPageState();
}
class _EventPageState extends State<EventPage> {
List<GuidedPathDTO> _paths = [];
@override
void initState() {
super.initState();
_loadPaths();
}
Future<void> _loadPaths() async {
if (widget.section.id == null) return;
if (widget.section.parcoursIds?.isEmpty ?? true) return;
try {
final paths = await widget.visitAppContextIn.clientAPI.sectionMapApi!
.sectionMapGetAllGuidedPathFromSection(widget.section.id!);
if (mounted) setState(() => _paths = paths ?? []);
} catch (_) {}
}
ProgrammeBlock? get _activeBlock {
final now = DateTime.now();
return widget.section.programme
?.where((b) =>
b.startTime != null &&
b.endTime != null &&
!now.isBefore(b.startTime!) &&
!now.isAfter(b.endTime!))
.firstOrNull;
}
String _formatTime(DateTime? dt) {
if (dt == null) return '';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
String _formatDate(DateTime dt) =>
'${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}';
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)}';
}
MapDTO _minimalMapDTO() => MapDTO(
latitude: widget.section.latitude,
longitude: widget.section.longitude,
zoom: 14,
);
@override
Widget build(BuildContext context) {
final hasProgramme = widget.section.programme?.isNotEmpty ?? false;
final annotations = widget.section.globalMapAnnotations ?? [];
final hasMapSection = annotations.isNotEmpty ||
widget.section.baseSectionMapId != null ||
widget.section.latitude != null ||
_paths.isNotEmpty;
final hasDescription = widget.section.description?.isNotEmpty ?? false;
return Scaffold(
backgroundColor: const Color(0xFF111111),
body: CustomScrollView(
slivers: [
_buildHero(context),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasProgramme) _buildProgrammeSection(),
if (hasMapSection) _buildMapParcoursSection(context, annotations),
if (hasDescription) _buildDescriptionSection(),
const SizedBox(height: 48),
],
),
),
],
),
);
}
// ─── Hero ──────────────────────────────────────────────────────────────────
Widget _buildHero(BuildContext context) {
final title = _safeTranslate(widget.section.title);
final dateRange = _formatDateRange();
final expandedHeight = MediaQuery.of(context).size.height * 0.52;
return SliverAppBar(
automaticallyImplyLeading: false,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: kMainColor,
title: Text(
title,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: Padding(
padding: const EdgeInsets.all(8),
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
decoration: const BoxDecoration(color: kMainColor, shape: BoxShape.circle),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
if (widget.section.imageSource != null)
Image.network(
widget.section.imageSource!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _gradientBackground(),
)
else
_gradientBackground(),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
stops: [0.35, 1.0],
),
),
),
Positioned(
bottom: 20,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (dateRange.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: kMainColor.withOpacity(0.85),
borderRadius: BorderRadius.circular(20),
),
child: Text(
dateRange,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
],
),
),
);
}
Widget _gradientBackground() => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [kMainColor, kSecondColor],
),
),
);
// ─── Programme ─────────────────────────────────────────────────────────────
Widget _buildProgrammeSection() {
final blocks = widget.section.programme!;
final active = _activeBlock;
return Padding(
padding: const EdgeInsets.only(top: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionHeader(Icons.schedule, 'Programme'),
const SizedBox(height: 8),
...blocks.asMap().entries.map(
(e) => _buildProgrammeBlock(e.value, e.value == active, e.key == blocks.length - 1),
),
],
),
);
}
Widget _buildProgrammeBlock(ProgrammeBlock block, bool isActive, bool isLast) {
final title = _safeTranslate(block.title);
final startStr = _formatTime(block.startTime);
final endStr = _formatTime(block.endTime);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 56,
child: Padding(
padding: const EdgeInsets.only(top: 14),
child: Text(
startStr,
style: TextStyle(
color: isActive ? kMainColor : Colors.grey[500],
fontSize: 12,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
),
textAlign: TextAlign.center,
),
),
),
Column(
children: [
const SizedBox(height: 16),
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: isActive ? kMainColor : Colors.grey[700],
shape: BoxShape.circle,
),
),
if (!isLast)
Expanded(
child: Container(width: 2, color: Colors.grey[800]),
),
],
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => _showBlockDetail(block),
child: Container(
margin: EdgeInsets.only(bottom: isLast ? 0 : 12, right: 16, top: 4),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isActive ? kMainColor : const Color(0xFF1e1e1e),
borderRadius: BorderRadius.circular(12),
border: isActive ? null : Border.all(color: Colors.grey[800]!, width: 1),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (startStr.isNotEmpty && endStr.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'$startStr $endStr',
style: TextStyle(
color: isActive ? Colors.white70 : Colors.grey[600],
fontSize: 12,
),
),
],
],
),
),
if (isActive)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'En cours',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
)
else
Icon(Icons.chevron_right, color: Colors.grey[700], size: 18),
],
),
),
),
),
],
),
);
}
void _showBlockDetail(ProgrammeBlock block) {
final title = _safeTranslate(block.title);
final desc = _safeTranslate(block.description);
final annotations = block.mapAnnotations ?? [];
showModalBottomSheet(
context: context,
backgroundColor: const Color(0xFF1e1e1e),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
isScrollControlled: true,
builder: (_) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[700],
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (desc.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
desc,
style: TextStyle(color: Colors.grey[400], fontSize: 14, height: 1.6),
),
],
if (annotations.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: Colors.grey[800]),
const SizedBox(height: 8),
...annotations.map((a) {
final label = _safeTranslate(a.label);
if (label.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(Icons.place, color: kMainColor, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
],
),
);
}),
],
],
),
),
);
}
// ─── Carte & Parcours ──────────────────────────────────────────────────────
Widget _buildMapParcoursSection(BuildContext context, List<MapAnnotationDTO> annotations) {
final lat = double.tryParse(widget.section.latitude ?? '');
final lng = double.tryParse(widget.section.longitude ?? '');
final center = (lat != null && lng != null) ? LatLng(lat, lng) : const LatLng(50.85, 4.35);
return Padding(
padding: const EdgeInsets.only(top: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionHeader(Icons.map_outlined, 'Carte & Parcours'),
const SizedBox(height: 12),
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => EventMapFullPage(
section: widget.section,
visitAppContextIn: widget.visitAppContextIn,
),
)),
child: Container(
height: 220,
margin: const EdgeInsets.symmetric(horizontal: 16),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)),
child: Stack(
children: [
FlutterMap(
options: MapOptions(
initialCenter: center,
initialZoom: 14,
interactionOptions:
const InteractionOptions(flags: InteractiveFlag.none),
),
children: [
TileLayer(
urlTemplate:
'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
userAgentPackageName: 'be.unov.myinfomate.visitapp',
),
if (annotations.isNotEmpty) ...[
MarkerLayer(markers: _buildMarkers(annotations)),
PolylineLayer(polylines: _buildPolylines(annotations)),
],
],
),
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.open_in_full, color: Colors.white, size: 12),
SizedBox(width: 4),
Text(
'Agrandir',
style: TextStyle(color: Colors.white, fontSize: 11),
),
],
),
),
),
],
),
),
),
if (_paths.isNotEmpty) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_paths.length} parcours disponible${_paths.length > 1 ? 's' : ''}',
style: TextStyle(color: Colors.grey[400], fontSize: 13),
),
GestureDetector(
onTap: () => _showParcoursList(context),
child: Text(
'Voir tout',
style: TextStyle(
color: kMainColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const SizedBox(height: 10),
SizedBox(
height: 84,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: _paths.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (_, i) => _buildParcoursChip(context, _paths[i]),
),
),
],
],
),
);
}
List<Marker> _buildMarkers(List<MapAnnotationDTO> annotations) {
final markers = <Marker>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 0) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List || coords.length < 2) continue;
final lat = (coords[1] as num).toDouble();
final lng = (coords[0] as num).toDouble();
markers.add(Marker(
point: LatLng(lat, lng),
width: 30,
height: 30,
child: Icon(Icons.place, color: kMainColor, size: 30),
));
}
return markers;
}
List<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations) {
final lines = <Polyline>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List) continue;
final points = <LatLng>[];
for (final c in coords) {
if (c is List && c.length >= 2) {
points.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()));
}
}
if (points.length < 2) continue;
final color = ann.polyColor != null ? _hexColor(ann.polyColor!) : kMainColor;
lines.add(Polyline(points: points, color: color, strokeWidth: 3.5));
}
return lines;
}
Color _hexColor(String hex) {
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
return v != null ? Color(v) : kMainColor;
}
Widget _buildParcoursChip(BuildContext context, GuidedPathDTO path) {
final title = _safeTranslate(path.title);
final stepCount = path.steps?.length ?? 0;
return GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathMapProgressionPage(
path: path,
mapDTO: _minimalMapDTO(),
visitAppContext: widget.visitAppContextIn,
),
)),
child: Container(
width: 160,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF1e1e1e),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[800]!, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.route, color: kMainColor, size: 15),
const SizedBox(width: 5),
Text(
'$stepCount étape${stepCount > 1 ? 's' : ''}',
style: TextStyle(color: Colors.grey[500], fontSize: 11),
),
],
),
const SizedBox(height: 6),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
void _showParcoursList(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: const Color(0xFF1e1e1e),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[700],
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Icon(Icons.route, size: 20, color: kMainColor),
const SizedBox(width: 8),
const Text(
'Parcours disponibles',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 8),
..._paths.map((path) {
final title = _safeTranslate(path.title);
final desc = _safeTranslate(path.description);
final stepCount = path.steps?.length ?? 0;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: kMainColor.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.map_outlined, color: kMainColor, size: 20),
),
title: Text(
title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
),
subtitle: Text(
desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[500]),
),
trailing: Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey[600]),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathMapProgressionPage(
path: path,
mapDTO: _minimalMapDTO(),
visitAppContext: widget.visitAppContextIn,
),
));
},
);
}),
],
),
),
);
}
// ─── Description ───────────────────────────────────────────────────────────
Widget _buildDescriptionSection() {
final desc = _safeTranslate(widget.section.description);
if (desc.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, color: kMainColor, size: 20),
const SizedBox(width: 8),
const Text(
'À propos',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Text(desc, style: TextStyle(color: Colors.grey[300], fontSize: 14, height: 1.6)),
],
),
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
Widget _sectionHeader(IconData icon, String label) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(icon, color: kMainColor, size: 20),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
);
String _safeTranslate(List<TranslationDTO>? list) {
if (list == null || list.isEmpty) return '';
try {
return TranslationHelper.get(list, widget.visitAppContextIn);
} catch (_) {
return list.first.value ?? '';
}
}
}