// 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'; // google_maps_flutter removed — using OpenStreetMap static map preview instead 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'; import '../core/utils/error_utils.dart'; import '../core/constants.dart'; import '../features/reviews/widgets/review_section.dart'; import '../widgets/tier_avatar_ring.dart'; import 'contributor_profile_screen.dart'; import 'checkout_screen.dart'; class LearnMoreScreen extends StatefulWidget { final int eventId; final EventModel? initialEvent; const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent}) : super(key: key); @override State createState() => _LearnMoreScreenState(); } class _LearnMoreScreenState extends State { final EventsService _service = EventsService(); void _navigateToCheckout() { if (!AuthGuard.requireLogin(context, reason: 'Sign in to book this event.')) return; if (_event == null) return; Navigator.of(context).push(MaterialPageRoute( builder: (_) => CheckoutScreen( eventId: _event!.id, eventName: _event!.name, eventImage: _event!.thumbImg, ), )); } bool _loading = true; EventModel? _event; String? _error; // Carousel final PageController _pageController = PageController(); late final ValueNotifier _pageNotifier; Timer? _autoScrollTimer; // About section bool _aboutExpanded = false; // Wishlist (UI-only) bool _wishlisted = false; // Google Map GoogleMapController? _mapController; // Related events (EVT-002) List _relatedEvents = []; bool _loadingRelated = false; @override void initState() { super.initState(); _pageNotifier = ValueNotifier(0); if (widget.initialEvent != null) { _event = widget.initialEvent; _loading = false; WidgetsBinding.instance.addPostFrameCallback((_) { _startAutoScroll(); // Fetch full event details in background to get important_information, images, etc. _loadFullDetails(); }); } else { _loadEvent(); } } @override void dispose() { _autoScrollTimer?.cancel(); _pageController.dispose(); _pageNotifier.dispose(); _mapController?.dispose(); super.dispose(); } // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- /// Fetch full event details to fill in fields missing from the list /// endpoint (important_information, images, etc.). Future _loadFullDetails() async { for (int attempt = 0; attempt < 2; attempt++) { try { final ev = await _service.getEventDetails(widget.eventId); if (!mounted) return; setState(() { _event = ev; }); _startAutoScroll(); _loadRelatedEvents(); return; // success } catch (e) { debugPrint('_loadFullDetails attempt ${attempt + 1} failed for event ${widget.eventId}: $e'); if (attempt == 0) { await Future.delayed(const Duration(seconds: 1)); // wait before retry } } } } Future _loadEvent() async { setState(() { _loading = true; _error = null; }); try { final ev = await _service.getEventDetails(widget.eventId); if (!mounted) return; setState(() => _event = ev); _startAutoScroll(); _loadRelatedEvents(); } catch (e) { if (!mounted) return; setState(() => _error = userFriendlyError(e)); } finally { if (mounted) setState(() => _loading = false); } } /// Fetch related events by the same event type (EVT-002). Future _loadRelatedEvents() async { if (_event?.eventTypeId == null) return; if (mounted) setState(() => _loadingRelated = true); try { final events = await _service.getEventsByCategory(_event!.eventTypeId!, limit: 6); final filtered = events.where((e) => e.id != widget.eventId).take(5).toList(); if (mounted) setState(() => _relatedEvents = filtered); } finally { if (mounted) setState(() => _loadingRelated = false); } } // --------------------------------------------------------------------------- // Carousel helpers // --------------------------------------------------------------------------- List get _imageUrls { final list = []; 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 _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 _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}'); } // --------------------------------------------------------------------------- // 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 screenWidth = mediaQuery.size.width; final screenHeight = mediaQuery.size.height; final imageHeight = screenHeight * 0.45; final topPadding = mediaQuery.padding.top; // ── DESKTOP layout ────────────────────────────────────────────────── if (screenWidth >= AppConstants.desktopBreakpoint) { final images = _imageUrls; final heroImage = images.isNotEmpty ? images[0] : null; final venueLabel = _event!.locationName ?? _event!.venueName ?? _event!.place ?? ''; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, body: SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Hero image with gradient overlay ── SizedBox( width: double.infinity, height: 300, child: Stack( fit: StackFit.expand, children: [ // Background image if (heroImage != null) CachedNetworkImage( imageUrl: heroImage, fit: BoxFit.cover, memCacheWidth: 800, memCacheHeight: 500, placeholder: (_, __) => Container( decoration: const BoxDecoration( gradient: LinearGradient( colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)], ), ), ), errorWidget: (_, __, ___) => Container( decoration: const BoxDecoration( gradient: LinearGradient( colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)], ), ), ), ) else Container( decoration: const BoxDecoration( gradient: LinearGradient( colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)], ), ), ), // Gradient overlay Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withOpacity(0.3), Colors.black.withOpacity(0.65), ], ), ), ), // Top bar: back + share + wishlist Positioned( top: topPadding + 10, left: 16, right: 16, child: Row( children: [ _squareIconButton(icon: Icons.arrow_back, onTap: () => Navigator.pop(context)), const SizedBox(width: 8), _squareIconButton(icon: Icons.ios_share_outlined, onTap: _shareEvent), const SizedBox(width: 8), _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); }, ), ], ), ), // Title + date + venue overlaid at bottom-left Positioned( left: 32, bottom: 28, right: 200, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _event!.title ?? _event!.name, style: theme.textTheme.headlineMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w800, fontSize: 28, height: 1.2, ), ), const SizedBox(height: 8), Row( children: [ const Icon(Icons.calendar_today_outlined, size: 16, color: Colors.white70), const SizedBox(width: 6), Text( _formattedDateRange(), style: const TextStyle(color: Colors.white70, fontSize: 15), ), if (venueLabel.isNotEmpty) ...[ const SizedBox(width: 16), const Icon(Icons.location_on_outlined, size: 16, color: Colors.white70), const SizedBox(width: 4), Flexible( child: Text( venueLabel, style: const TextStyle(color: Colors.white70, fontSize: 15), overflow: TextOverflow.ellipsis, ), ), ], ], ), ], ), ), // "Book Your Spot" CTA on the right Positioned( right: 32, bottom: 36, child: ElevatedButton( onPressed: _navigateToCheckout, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1A56DB), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 4, ), child: const Text( 'Book Your Spot', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), ), ), ], ), ), const SizedBox(height: 28), // ── Two-column: About (left 60%) + Venue/Map (right 40%) ── Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Left column — About the Event Expanded( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAboutSection(theme), if (_event!.importantInfo.isNotEmpty) _buildImportantInfoSection(theme), if (_event!.importantInfo.isEmpty && (_event!.importantInformation ?? '').isNotEmpty) _buildImportantInfoFallback(theme), // EVT-001: Contributor widget _buildContributorSection(theme), const SizedBox(height: 24), ReviewSection(eventId: widget.eventId), // EVT-002: Related events horizontal row _buildRelatedEventsSection(theme), ], ), ), const SizedBox(width: 32), // Right column — Venue / map Expanded( flex: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_event!.latitude != null && _event!.longitude != null) ...[ _buildVenueSection(theme), const SizedBox(height: 12), _buildGetDirectionsButton(theme), ], ], ), ), ], ), ), // ── Gallery: horizontal scrollable image strip ── if (images.length > 1) ...[ const SizedBox(height: 32), Padding( padding: const EdgeInsets.only(left: 32), child: Text( 'Gallery', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, fontSize: 20, ), ), ), const SizedBox(height: 14), SizedBox( height: 160, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 32), itemCount: images.length > 6 ? 6 : images.length, itemBuilder: (context, i) { // Show overflow count badge on last visible item final isLast = i == 5 && images.length > 6; return Padding( padding: const EdgeInsets.only(right: 12), child: ClipRRect( borderRadius: BorderRadius.circular(14), child: SizedBox( width: 220, child: Stack( fit: StackFit.expand, children: [ CachedNetworkImage( imageUrl: images[i], fit: BoxFit.cover, memCacheWidth: 800, memCacheHeight: 500, placeholder: (_, __) => Container(color: theme.dividerColor), errorWidget: (_, __, ___) => Container( color: theme.dividerColor, child: Icon(Icons.broken_image, color: theme.hintColor), ), ), if (isLast) Container( color: Colors.black.withOpacity(0.55), alignment: Alignment.center, child: Text( '+${images.length - 6}', style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ); }, ), ), ], const SizedBox(height: 80), ], ), ), ); } // ── MOBILE layout ───────────────────────────────────────────────────── return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, bottomNavigationBar: (_event != null && _event!.isBookable) ? Container( decoration: BoxDecoration( color: theme.scaffoldBackgroundColor, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.08), blurRadius: 12, offset: const Offset(0, -4), ), ], ), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: SafeArea( top: false, child: SizedBox( height: 52, child: ElevatedButton( onPressed: _navigateToCheckout, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1A56DB), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), elevation: 0, ), child: const Text( 'Book Now', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), ), ), ), ) : null, 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), // EVT-001: Contributor widget _buildContributorSection(theme), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 0), child: ReviewSection(eventId: widget.eventId), ), // EVT-002: Related events horizontal row _buildRelatedEventsSection(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( 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( valueListenable: _pageNotifier, builder: (context, currentPage, _) => CachedNetworkImage( imageUrl: images[currentPage], fit: BoxFit.cover, memCacheWidth: 800, memCacheHeight: 500, 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, memCacheWidth: 800, memCacheHeight: 500, 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 (Native Google Map on mobile, fallback on web) // --------------------------------------------------------------------------- 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: 250, width: double.infinity, child: Stack( children: [ // Native Google Maps SDK on mobile, tappable fallback on web if (kIsWeb) GestureDetector( onTap: _viewLargerMap, child: Container( color: const Color(0xFFE8EAF6), child: Center( 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, ), markers: { Marker( markerId: const MarkerId('event'), position: LatLng(lat, lng), infoWindow: InfoWindow(title: venueLabel), ), }, myLocationButtonEnabled: false, zoomControlsEnabled: true, scrollGesturesEnabled: true, rotateGesturesEnabled: false, tiltGesturesEnabled: false, onMapCreated: (c) => _mapController = c, ), // "View larger map" overlay button — 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.withValues(alpha: 0.12), blurRadius: 6), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.open_in_new, size: 14, color: theme.colorScheme.primary), const SizedBox(width: 4), Text( 'View larger map', style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600, fontSize: 13), ), ], ), ), ), ), ], ), ), ), // 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.withValues(alpha: 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)), ), ], ), ), ], ), ); } // --------------------------------------------------------------------------- // 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> _parseHtmlImportantInfo(String raw) { var text = raw; // 1. Remove blocks entirely (content + tags) text = text.replaceAll(RegExp(r']*>.*?', caseSensitive: false, dotAll: true), ''); // 2. Remove blocks text = text.replaceAll(RegExp(r']*>.*?', caseSensitive: false, dotAll: true), ''); // 3. Convert block-level closers to newlines text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n'); text = text.replaceAll(RegExp(r'

', caseSensitive: false), '\n'); text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n'); // 4. Convert
to newlines text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n'); // 5. Strip all remaining HTML tags text = text.replaceAll(RegExp(r'<[^>]*>'), ''); // 6. Decode HTML 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
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 = >[]; 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; } // --------------------------------------------------------------------------- // 8. CONTRIBUTOR WIDGET (EVT-001) // --------------------------------------------------------------------------- Widget _buildContributorSection(ThemeData theme) { final name = _event?.contributorName; if (name == null || name.isEmpty) return const SizedBox.shrink(); final tier = _event!.contributorTier ?? ''; return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.brightness == Brightness.dark ? const Color(0xFF1E293B) : theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.brightness == Brightness.dark ? Colors.white.withOpacity(0.08) : theme.dividerColor, ), ), child: Row( children: [ TierAvatarRing( username: name, tier: tier, size: 40, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Contributed by', style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, fontSize: 11, ), ), const SizedBox(height: 2), Text( name, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), if (tier.isNotEmpty) Container( margin: const EdgeInsets.only(top: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: theme.colorScheme.primary.withOpacity(0.15), borderRadius: BorderRadius.circular(4), ), child: Text( tier, style: TextStyle( fontSize: 10, color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), ], ), ), if (_event?.contributorId != null) IconButton( icon: Icon(Icons.arrow_forward_ios, size: 14, color: theme.hintColor), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (_) => ContributorProfileScreen( contributorId: _event!.contributorId!, contributorName: _event!.contributorName!, ), ), ); }, ), ], ), ), ); } // --------------------------------------------------------------------------- // 9. RELATED EVENTS ROW (EVT-002) // --------------------------------------------------------------------------- Widget _buildRelatedEventsSection(ThemeData theme) { if (_loadingRelated) { return Padding( padding: const EdgeInsets.fromLTRB(20, 28, 20, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Related Events', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, fontSize: 18, ), ), const SizedBox(height: 12), const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ), ); } if (_relatedEvents.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 28, 20, 8), child: Text( 'Related Events', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, fontSize: 18, ), ), ), SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _relatedEvents.length, itemBuilder: (context, i) { final e = _relatedEvents[i]; final displayName = e.title ?? e.name; final imageUrl = e.thumbImg ?? ''; return GestureDetector( onTap: () => Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => LearnMoreScreen(eventId: e.id), ), ), child: Container( width: 140, margin: const EdgeInsets.only(right: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: theme.brightness == Brightness.dark ? const Color(0xFF1E293B) : theme.cardColor, boxShadow: [ BoxShadow( color: theme.shadowColor.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(12), ), child: imageUrl.isNotEmpty ? CachedNetworkImage( imageUrl: imageUrl, height: 100, width: 140, fit: BoxFit.cover, errorWidget: (_, __, ___) => Container( height: 100, width: 140, color: theme.dividerColor, child: Icon(Icons.event, size: 32, color: theme.hintColor), ), ) : Container( height: 100, width: 140, color: theme.dividerColor, child: Icon(Icons.event, size: 32, color: theme.hintColor), ), ), Padding( padding: const EdgeInsets.all(8), child: Text( displayName, maxLines: 3, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, height: 1.35, ), ), ), ], ), ), ); }, ), ), const SizedBox(height: 8), ], ); } 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, ), ), ], ], ), ), ], ), ), ], ), ); } }