Files
Eventify-frontend/lib/screens/learn_more_screen.dart

1686 lines
63 KiB
Dart
Raw 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.
// 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';
import '../core/analytics/posthog_service.dart';
class LearnMoreScreen extends StatefulWidget {
final int eventId;
final EventModel? initialEvent;
final String? heroTag;
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent, this.heroTag}) : super(key: key);
@override
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
}
class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProviderStateMixin {
final EventsService _service = EventsService();
late final AnimationController _fadeController;
late final Animation<double> _fade;
void _navigateToCheckout() {
if (!AuthGuard.requireLogin(context, reason: 'Sign in to book this event.')) return;
if (_event == null) return;
PostHogService.instance.capture('book_now_tapped', properties: {
'event_id': _event!.id,
'event_name': _event!.name ?? _event!.title ?? '',
});
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<int> _pageNotifier;
Timer? _autoScrollTimer;
// About section
bool _aboutExpanded = false;
// Wishlist (UI-only)
bool _wishlisted = false;
// Google Map
GoogleMapController? _mapController;
// Related events (EVT-002)
List<EventModel> _relatedEvents = [];
bool _loadingRelated = false;
@override
void initState() {
super.initState();
PostHogService.instance.screen('EventDetail', properties: {'event_id': widget.eventId});
_fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 350));
_fade = CurvedAnimation(parent: _fadeController, curve: Curves.easeIn);
_pageNotifier = ValueNotifier(0);
if (widget.initialEvent != null) {
_event = widget.initialEvent;
_loading = false;
_fadeController.forward();
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();
_fadeController.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
/// Fetch full event details to fill in fields missing from the list
/// endpoint (important_information, images, etc.).
Future<void> _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<void> _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);
_fadeController.forward();
}
}
}
/// Fetch related events by the same event type (EVT-002).
Future<void> _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<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}');
}
// ---------------------------------------------------------------------------
// 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.52;
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: FadeTransition(
opacity: _fade,
child: 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<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,
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 + 70, // safely below the icon row
left: 20,
right: 20,
bottom: 40, // clear from the bottom card's -28 overlap
child: Hero(
tag: widget.heroTag ?? 'event-hero-${widget.eventId}',
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 + 70,
left: 20,
right: 20,
bottom: 40,
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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
return text.trim();
}
/// Parse an HTML important_information string into a list of {title, value} maps
List<Map<String, String>> _parseHtmlImportantInfo(String raw) {
var text = raw;
// 1. Remove <style>...</style> blocks entirely (content + tags)
text = text.replaceAll(RegExp(r'<style[^>]*>.*?</style>', caseSensitive: false, dotAll: true), '');
// 2. Remove <script>...</script> blocks
text = text.replaceAll(RegExp(r'<script[^>]*>.*?</script>', caseSensitive: false, dotAll: true), '');
// 3. Convert block-level closers to newlines
text = text.replaceAll(RegExp(r'</div>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</p>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</li>', caseSensitive: false), '\n');
// 4. Convert <br> to newlines
text = text.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n');
// 5. Strip all remaining HTML tags
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// 6. Decode HTML entities
text = text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
// 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;
}
// ---------------------------------------------------------------------------
// 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,
),
),
],
],
),
),
],
),
),
],
),
);
}
}