- Commit untracked posthog_service.dart (fire-and-forget HTTP client,
EU data residency, already used by auth for identify/reset)
- screen() calls: Home, Contribute, Profile, EventDetail (with event_id)
- capture('event_tapped') on hero carousel card tap (source: hero_carousel)
- capture('book_now_tapped') in _navigateToCheckout (event_id + name)
- capture('review_submitted') in _handleSubmit (event_id + rating)
- Covers all 4 expansion items from security audit finding 8.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1685 lines
63 KiB
Dart
1685 lines
63 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';
|
||
// 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;
|
||
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent}) : 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.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: 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 + 56, // below the icon row
|
||
left: 20,
|
||
right: 20,
|
||
bottom: 16,
|
||
child: Hero(
|
||
tag: '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 + 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<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('&', '&')
|
||
.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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|