The guest button was nearly invisible (grey text, fontSize 13 on dark background). Now uses white70, fontSize 15, TextButton with proper tap padding. Also guards wishlist toggle on event detail page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1194 lines
43 KiB
Dart
1194 lines
43 KiB
Dart
// lib/screens/learn_more_screen.dart
|
||
import 'dart:async';
|
||
import 'dart:ui';
|
||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
|
||
import '../features/events/models/event_models.dart';
|
||
import '../features/events/services/events_service.dart';
|
||
import '../core/auth/auth_guard.dart';
|
||
|
||
class LearnMoreScreen extends StatefulWidget {
|
||
final int eventId;
|
||
const LearnMoreScreen({Key? key, required this.eventId}) : super(key: key);
|
||
|
||
@override
|
||
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
|
||
}
|
||
|
||
class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||
final EventsService _service = EventsService();
|
||
|
||
bool _loading = true;
|
||
EventModel? _event;
|
||
String? _error;
|
||
|
||
// Carousel
|
||
final PageController _pageController = PageController();
|
||
late final ValueNotifier<int> _pageNotifier;
|
||
Timer? _autoScrollTimer;
|
||
|
||
// About section
|
||
bool _aboutExpanded = false;
|
||
|
||
// Wishlist (UI-only)
|
||
bool _wishlisted = false;
|
||
|
||
// Google Map
|
||
GoogleMapController? _mapController;
|
||
MapType _mapType = MapType.normal;
|
||
bool _showMapControls = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_pageNotifier = ValueNotifier(0);
|
||
_loadEvent();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_autoScrollTimer?.cancel();
|
||
_pageController.dispose();
|
||
_pageNotifier.dispose();
|
||
_mapController?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data loading
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _loadEvent() async {
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final ev = await _service.getEventDetails(widget.eventId);
|
||
if (!mounted) return;
|
||
setState(() => _event = ev);
|
||
_startAutoScroll();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _error = e.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Carousel helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
List<String> get _imageUrls {
|
||
final list = <String>[];
|
||
if (_event == null) return list;
|
||
final thumb = _event!.thumbImg;
|
||
if (thumb != null && thumb.isNotEmpty) list.add(thumb);
|
||
for (final img in _event!.images) {
|
||
if (img.image.isNotEmpty && !list.contains(img.image)) list.add(img.image);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
void _startAutoScroll() {
|
||
_autoScrollTimer?.cancel();
|
||
final count = _imageUrls.length;
|
||
if (count <= 1) return;
|
||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||
if (!_pageController.hasClients) return;
|
||
final next = (_pageNotifier.value + 1) % count;
|
||
_pageController.animateToPage(next,
|
||
duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Date formatting
|
||
// ---------------------------------------------------------------------------
|
||
|
||
String _formattedDateRange() {
|
||
if (_event == null) return '';
|
||
try {
|
||
final s = DateTime.parse(_event!.startDate);
|
||
final e = DateTime.parse(_event!.endDate);
|
||
const months = [
|
||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||
];
|
||
if (s.year == e.year && s.month == e.month && s.day == e.day) {
|
||
return '${s.day} ${months[s.month - 1]}';
|
||
}
|
||
if (s.month == e.month && s.year == e.year) {
|
||
return '${s.day} - ${e.day} ${months[s.month - 1]}';
|
||
}
|
||
return '${s.day} ${months[s.month - 1]} - ${e.day} ${months[e.month - 1]}';
|
||
} catch (_) {
|
||
return '${_event!.startDate} – ${_event!.endDate}';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Actions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _shareEvent() async {
|
||
final title = _event?.title ?? _event?.name ?? 'Check out this event';
|
||
final url =
|
||
'https://uat.eventifyplus.com/events/${widget.eventId}';
|
||
await Share.share('$title\n$url', subject: title);
|
||
}
|
||
|
||
Future<void> _openUrl(String url) async {
|
||
final uri = Uri.parse(url);
|
||
if (await canLaunchUrl(uri)) {
|
||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
}
|
||
}
|
||
|
||
void _viewLargerMap() {
|
||
if (_event?.latitude == null || _event?.longitude == null) return;
|
||
_openUrl(
|
||
'https://www.google.com/maps/search/?api=1&query=${_event!.latitude},${_event!.longitude}');
|
||
}
|
||
|
||
void _getDirections() {
|
||
if (_event?.latitude == null || _event?.longitude == null) return;
|
||
_openUrl(
|
||
'https://www.google.com/maps/dir/?api=1&destination=${_event!.latitude},${_event!.longitude}');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Map camera helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void _moveCamera(double latDelta, double lngDelta) {
|
||
_mapController?.animateCamera(CameraUpdate.scrollBy(lngDelta * 80, -latDelta * 80));
|
||
}
|
||
|
||
void _zoom(double amount) {
|
||
_mapController?.animateCamera(CameraUpdate.zoomBy(amount));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BUILD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
if (_loading) {
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
body: _buildLoadingShimmer(theme),
|
||
);
|
||
}
|
||
|
||
if (_error != null) {
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
body: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.error_outline, size: 56, color: theme.colorScheme.error),
|
||
const SizedBox(height: 16),
|
||
Text('Something went wrong',
|
||
style: theme.textTheme.titleMedium
|
||
?.copyWith(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 8),
|
||
Text(_error!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium),
|
||
const SizedBox(height: 24),
|
||
ElevatedButton.icon(
|
||
onPressed: _loadEvent,
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('Retry'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_event == null) {
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
body: const Center(child: Text('Event not found')),
|
||
);
|
||
}
|
||
|
||
final mediaQuery = MediaQuery.of(context);
|
||
final screenHeight = mediaQuery.size.height;
|
||
final imageHeight = screenHeight * 0.45;
|
||
final topPadding = mediaQuery.padding.top;
|
||
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
body: Stack(
|
||
children: [
|
||
// ── Scrollable content (carousel + card scroll together) ──
|
||
SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Image carousel (scrolls with content)
|
||
_buildImageCarousel(theme, imageHeight),
|
||
|
||
// Content card with rounded top corners overlapping carousel
|
||
Transform.translate(
|
||
offset: const Offset(0, -28),
|
||
child: Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: theme.scaffoldBackgroundColor,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(28),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.08),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, -6),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildTitleSection(theme),
|
||
_buildAboutSection(theme),
|
||
if (_event!.latitude != null && _event!.longitude != null) ...[
|
||
_buildVenueSection(theme),
|
||
_buildGetDirectionsButton(theme),
|
||
],
|
||
if (_event!.importantInfo.isNotEmpty)
|
||
_buildImportantInfoSection(theme),
|
||
if (_event!.importantInfo.isEmpty &&
|
||
(_event!.importantInformation ?? '').isNotEmpty)
|
||
_buildImportantInfoFallback(theme),
|
||
const SizedBox(height: 100),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── Fixed top bar with back/share/heart buttons ──
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Container(
|
||
padding: EdgeInsets.only(
|
||
top: topPadding + 10,
|
||
bottom: 10,
|
||
left: 16,
|
||
right: 16,
|
||
),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.black.withOpacity(0.5),
|
||
Colors.black.withOpacity(0.0),
|
||
],
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_squareIconButton(
|
||
icon: Icons.arrow_back,
|
||
onTap: () => Navigator.pop(context),
|
||
),
|
||
// Pill-shaped page indicators (centered)
|
||
Expanded(
|
||
child: _imageUrls.length > 1
|
||
? ValueListenableBuilder<int>(
|
||
valueListenable: _pageNotifier,
|
||
builder: (context, currentPage, _) => Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: List.generate(_imageUrls.length, (i) {
|
||
final active = i == currentPage;
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||
width: active ? 18 : 8,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: active
|
||
? Colors.white
|
||
: Colors.white.withOpacity(0.45),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
_squareIconButton(
|
||
icon: Icons.ios_share_outlined,
|
||
onTap: _shareEvent,
|
||
),
|
||
const SizedBox(width: 10),
|
||
_squareIconButton(
|
||
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
|
||
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
|
||
onTap: () {
|
||
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
|
||
setState(() => _wishlisted = !_wishlisted);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. LOADING SHIMMER
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildLoadingShimmer(ThemeData theme) {
|
||
final shimmerHeight = MediaQuery.of(context).size.height;
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Placeholder image
|
||
Container(
|
||
height: shimmerHeight * 0.42,
|
||
decoration: BoxDecoration(
|
||
color: theme.dividerColor.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(28),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
// Placeholder title
|
||
Container(
|
||
height: 28,
|
||
width: 220,
|
||
decoration: BoxDecoration(
|
||
color: theme.dividerColor.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
height: 16,
|
||
width: 140,
|
||
decoration: BoxDecoration(
|
||
color: theme.dividerColor.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Container(
|
||
height: 16,
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: theme.dividerColor.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
height: 16,
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: theme.dividerColor.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. IMAGE CAROUSEL WITH BLURRED BACKGROUND
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildImageCarousel(ThemeData theme, double carouselHeight) {
|
||
final images = _imageUrls;
|
||
final topPad = MediaQuery.of(context).padding.top;
|
||
|
||
return SizedBox(
|
||
height: carouselHeight,
|
||
child: Stack(
|
||
children: [
|
||
// ---- Blurred background (image or blue gradient) ----
|
||
Positioned.fill(
|
||
child: images.isNotEmpty
|
||
? ClipRect(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: _pageNotifier,
|
||
builder: (context, currentPage, _) => CachedNetworkImage(
|
||
imageUrl: images[currentPage],
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
placeholder: (_, __) => Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||
),
|
||
),
|
||
),
|
||
errorWidget: (_, __, ___) => Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
|
||
child: Container(
|
||
color: Colors.black.withOpacity(0.15),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ---- Foreground image with rounded corners ----
|
||
if (images.isNotEmpty)
|
||
Positioned(
|
||
top: topPad + 56, // below the icon row
|
||
left: 20,
|
||
right: 20,
|
||
bottom: 16,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: PageView.builder(
|
||
controller: _pageController,
|
||
onPageChanged: (i) => _pageNotifier.value = i,
|
||
itemCount: images.length,
|
||
itemBuilder: (_, i) => CachedNetworkImage(
|
||
imageUrl: images[i],
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
placeholder: (_, __) => Container(
|
||
color: theme.dividerColor,
|
||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
),
|
||
errorWidget: (_, __, ___) => Container(
|
||
color: theme.dividerColor,
|
||
child: Icon(Icons.broken_image, size: 48, color: theme.hintColor),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ---- No-image placeholder ----
|
||
if (images.isEmpty)
|
||
Positioned(
|
||
top: topPad + 56,
|
||
left: 20,
|
||
right: 20,
|
||
bottom: 16,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.15),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: const Center(
|
||
child: Icon(Icons.event, size: 80, color: Colors.white70),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Square icon button with rounded corners and prominent background
|
||
Widget _squareIconButton({
|
||
required IconData icon,
|
||
required VoidCallback onTap,
|
||
Color iconColor = Colors.white,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.35),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||
),
|
||
child: Icon(icon, color: iconColor, size: 22),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. TITLE & DATE
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildTitleSection(ThemeData theme) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
_event!.title ?? _event!.name,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 26,
|
||
height: 1.25,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Icon(Icons.calendar_today_outlined,
|
||
size: 16, color: theme.hintColor),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_formattedDateRange(),
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.hintColor,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. ABOUT THE EVENT
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildAboutSection(ThemeData theme) {
|
||
final desc = _event!.description ?? '';
|
||
if (desc.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'About the Event',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 20,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
AnimatedCrossFade(
|
||
firstChild: Text(
|
||
desc,
|
||
maxLines: 4,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
height: 1.55,
|
||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.75),
|
||
),
|
||
),
|
||
secondChild: Text(
|
||
desc,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
height: 1.55,
|
||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.75),
|
||
),
|
||
),
|
||
crossFadeState:
|
||
_aboutExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
||
duration: const Duration(milliseconds: 300),
|
||
),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _aboutExpanded = !_aboutExpanded),
|
||
child: Text(
|
||
_aboutExpanded ? 'Read Less' : 'Read More',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 5. VENUE LOCATION (Google Map)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildVenueSection(ThemeData theme) {
|
||
final lat = _event!.latitude!;
|
||
final lng = _event!.longitude!;
|
||
final venueLabel = _event!.locationName ?? _event!.venueName ?? _event!.place ?? '';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Venue Location',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 20,
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// Map container
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: SizedBox(
|
||
height: 280,
|
||
child: Stack(
|
||
children: [
|
||
// Use static map image on web (Google Maps JS SDK not configured),
|
||
// native GoogleMap widget on mobile
|
||
if (kIsWeb)
|
||
GestureDetector(
|
||
onTap: _viewLargerMap,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade200,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: CachedNetworkImage(
|
||
imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_, __, ___) => Container(
|
||
color: const Color(0xFFE8EAF6),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.map_outlined, size: 48, color: theme.colorScheme.primary),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Tap to view on Google Maps',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
else
|
||
GoogleMap(
|
||
initialCameraPosition: CameraPosition(
|
||
target: LatLng(lat, lng),
|
||
zoom: 15,
|
||
),
|
||
mapType: _mapType,
|
||
markers: {
|
||
Marker(
|
||
markerId: const MarkerId('event'),
|
||
position: LatLng(lat, lng),
|
||
infoWindow: InfoWindow(title: venueLabel),
|
||
),
|
||
},
|
||
myLocationButtonEnabled: false,
|
||
zoomControlsEnabled: false,
|
||
scrollGesturesEnabled: true,
|
||
rotateGesturesEnabled: false,
|
||
tiltGesturesEnabled: false,
|
||
onMapCreated: (c) => _mapController = c,
|
||
),
|
||
|
||
// "View larger map" – top left
|
||
Positioned(
|
||
top: 10,
|
||
left: 10,
|
||
child: GestureDetector(
|
||
onTap: _viewLargerMap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.12),
|
||
blurRadius: 6,
|
||
),
|
||
],
|
||
),
|
||
child: Text(
|
||
'View larger map',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Map type toggle – bottom left (native only)
|
||
if (!kIsWeb)
|
||
Positioned(
|
||
bottom: 12,
|
||
left: 12,
|
||
child: _mapControlButton(
|
||
icon: _mapType == MapType.normal
|
||
? Icons.satellite_alt
|
||
: Icons.map_outlined,
|
||
onTap: () {
|
||
setState(() {
|
||
_mapType = _mapType == MapType.normal
|
||
? MapType.satellite
|
||
: MapType.normal;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
|
||
// Map controls toggle – bottom right (native only)
|
||
if (!kIsWeb)
|
||
Positioned(
|
||
bottom: 12,
|
||
right: 12,
|
||
child: _mapControlButton(
|
||
icon: Icons.open_with_rounded,
|
||
onTap: () => setState(() => _showMapControls = !_showMapControls),
|
||
),
|
||
),
|
||
|
||
// Directional pad overlay (native only)
|
||
if (!kIsWeb && _showMapControls)
|
||
Positioned.fill(
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.25),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_mapControlButton(
|
||
icon: Icons.keyboard_arrow_up,
|
||
onTap: () => _moveCamera(1, 0)),
|
||
const SizedBox(width: 16),
|
||
_mapControlButton(
|
||
icon: Icons.add,
|
||
onTap: () => _zoom(1)),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_mapControlButton(
|
||
icon: Icons.keyboard_arrow_left,
|
||
onTap: () => _moveCamera(0, -1)),
|
||
const SizedBox(width: 60),
|
||
_mapControlButton(
|
||
icon: Icons.keyboard_arrow_right,
|
||
onTap: () => _moveCamera(0, 1)),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_mapControlButton(
|
||
icon: Icons.keyboard_arrow_down,
|
||
onTap: () => _moveCamera(-1, 0)),
|
||
const SizedBox(width: 16),
|
||
_mapControlButton(
|
||
icon: Icons.remove,
|
||
onTap: () => _zoom(-1)),
|
||
const SizedBox(width: 16),
|
||
_mapControlButton(
|
||
icon: Icons.close,
|
||
onTap: () =>
|
||
setState(() => _showMapControls = false)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// Venue name card
|
||
if (venueLabel.isNotEmpty)
|
||
Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(top: 14),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: theme.cardColor,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: theme.shadowColor.withOpacity(0.06),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
venueLabel,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
if (_event!.place != null && _event!.place != venueLabel)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
_event!.place!,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.hintColor,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _mapControlButton({
|
||
required IconData icon,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.92),
|
||
shape: BoxShape.circle,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.15),
|
||
blurRadius: 6,
|
||
),
|
||
],
|
||
),
|
||
child: Icon(icon, color: Colors.grey.shade700, size: 22),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 6. GET DIRECTIONS BUTTON
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildGetDirectionsButton(ThemeData theme) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 0),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 54,
|
||
child: ElevatedButton.icon(
|
||
onPressed: _getDirections,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: theme.colorScheme.primary,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
elevation: 2,
|
||
),
|
||
icon: const Icon(Icons.directions, size: 22),
|
||
label: const Text(
|
||
'Get Directions',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7. IMPORTANT INFORMATION (structured list)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Widget _buildImportantInfoSection(ThemeData theme) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Important Information',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 20,
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
for (final info in _event!.importantInfo)
|
||
Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withOpacity(0.05),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: theme.colorScheme.primary.withOpacity(0.12),
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(Icons.info_outline,
|
||
size: 20, color: theme.colorScheme.primary),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
info['title'] ?? '',
|
||
style: theme.textTheme.bodyLarge?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
info['value'] ?? '',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.hintColor,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7b. IMPORTANT INFO FALLBACK (parse HTML string into cards)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Strip HTML tags and decode common HTML entities
|
||
String _stripHtml(String html) {
|
||
// Remove all HTML tags
|
||
var text = html.replaceAll(RegExp(r'<[^>]*>'), '');
|
||
// Decode common HTML entities
|
||
text = text
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll(' ', ' ');
|
||
return text.trim();
|
||
}
|
||
|
||
/// Parse an HTML important_information string into a list of {title, value} maps
|
||
List<Map<String, String>> _parseHtmlImportantInfo(String raw) {
|
||
// Strip HTML tags, preserving <br> as a newline separator first
|
||
var text = raw
|
||
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
||
.replaceAll(RegExp(r'<[^>]*>'), '');
|
||
// Decode entities
|
||
text = text
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll(' ', ' ');
|
||
|
||
// Split by newlines first
|
||
var lines = text
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.where((l) => l.isNotEmpty)
|
||
.toList();
|
||
|
||
// If we only have 1 line, items might be separated by emoji characters
|
||
// (some categories don't use <br> between items, e.g. "...etc.🚌 Bus:")
|
||
if (lines.length <= 1 && text.trim().isNotEmpty) {
|
||
final parts = text.trim().split(
|
||
RegExp(r'(?=[\u2600-\u27BF]|[\u{1F300}-\u{1FFFF}])', unicode: true),
|
||
);
|
||
final emojiLines = parts
|
||
.map((l) => l.trim())
|
||
.where((l) => l.isNotEmpty)
|
||
.toList();
|
||
if (emojiLines.length > 1) {
|
||
lines = emojiLines;
|
||
}
|
||
}
|
||
|
||
final items = <Map<String, String>>[];
|
||
for (final line in lines) {
|
||
// Split on first colon to get title:value
|
||
final colonIdx = line.indexOf(':');
|
||
if (colonIdx > 0 && colonIdx < line.length - 1) {
|
||
items.add({
|
||
'title': line.substring(0, colonIdx + 1).trim(),
|
||
'value': line.substring(colonIdx + 1).trim(),
|
||
});
|
||
} else {
|
||
items.add({'title': line, 'value': ''});
|
||
}
|
||
}
|
||
return items;
|
||
}
|
||
|
||
Widget _buildImportantInfoFallback(ThemeData theme) {
|
||
final parsed = _parseHtmlImportantInfo(_event!.importantInformation!);
|
||
|
||
if (parsed.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Important Information',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 20,
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
for (final info in parsed)
|
||
Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withOpacity(0.05),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: theme.colorScheme.primary.withOpacity(0.12),
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(Icons.info_outline,
|
||
size: 20, color: theme.colorScheme.primary),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
info['title'] ?? '',
|
||
style: theme.textTheme.bodyLarge?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
if ((info['value'] ?? '').isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
info['value']!,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.hintColor,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|