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

2420 lines
92 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/home_screen.dart
import 'dart:async';
import 'dart:ui';
import '../core/utils/error_utils.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/auth/auth_guard.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart';
import 'calendar_screen.dart';
import 'profile_screen.dart';
import 'contribute_screen.dart';
import 'learn_more_screen.dart';
import 'search_screen.dart';
import '../core/app_decoration.dart';
import 'package:geocoding/geocoding.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import '../features/gamification/providers/gamification_provider.dart';
import '../features/notifications/widgets/notification_bell.dart';
import '../features/notifications/providers/notification_provider.dart';
import '../widgets/skeleton_loader.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
/// Main screen that hosts 4 tabs in an IndexedStack (Home, Calendar, Contribute, Profile).
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
int _selectedIndex = 0;
String _username = '';
String _location = '';
String _pincode = 'all';
final EventsService _eventsService = EventsService();
// backend-driven
List<EventModel> _allEvents = []; // master copy, never filtered
List<EventModel> _events = [];
List<EventTypeModel> _types = [];
int _selectedTypeId = -1; // -1 == All
bool _categoriesExpanded = false;
bool _loading = true;
// Hero carousel
final PageController _heroPageController = PageController(viewportFraction: 0.9);
late final ValueNotifier<int> _heroPageNotifier;
Timer? _autoScrollTimer;
@override
void initState() {
super.initState();
_heroPageNotifier = ValueNotifier(0);
_loadUserDataAndEvents();
_startAutoScroll();
}
@override
void dispose() {
_autoScrollTimer?.cancel();
_heroPageController.dispose();
_heroPageNotifier.dispose();
super.dispose();
}
void _startAutoScroll({Duration delay = const Duration(seconds: 5)}) {
_autoScrollTimer?.cancel();
_autoScrollTimer = Timer.periodic(delay, (timer) {
if (_heroEvents.isEmpty) return;
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
});
}
Future<void> _loadUserDataAndEvents() async {
setState(() => _loading = true);
final prefs = await SharedPreferences.getInstance();
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? '';
final storedLocation = prefs.getString('location') ?? 'Whitefield, Bengaluru';
// Fix legacy lat,lng strings saved before the reverse-geocoding fix
final coordMatch = RegExp(r'^(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$').firstMatch(storedLocation);
if (coordMatch != null) {
_location = 'Current Location';
// Reverse geocode in background to get actual place name
_reverseGeocodeAndSave(
double.parse(coordMatch.group(1)!),
double.parse(coordMatch.group(2)!),
prefs,
);
} else {
_location = storedLocation;
}
_pincode = prefs.getString('pincode') ?? 'all';
try {
// Fetch types and events in parallel for faster loading
final results = await Future.wait([
_events_service_getEventTypesSafe(),
_events_service_getEventsSafe(_pincode),
]);
final types = results[0] as List<EventTypeModel>;
final events = results[1] as List<EventModel>;
if (mounted) {
setState(() {
_types = types;
_allEvents = events;
_events = events;
_selectedTypeId = -1;
_cachedFilteredEvents = null;
_cachedEventDates = null;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
}
}
// Refresh notification badge count (fire-and-forget)
if (mounted) {
context.read<NotificationProvider>().refreshUnreadCount();
}
}
Future<void> _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async {
try {
final placemarks = await placemarkFromCoordinates(lat, lng);
if (placemarks.isNotEmpty) {
final p = placemarks.first;
final parts = <String>[];
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
await prefs.setString('location', label);
if (mounted) setState(() => _location = label);
return;
}
} catch (_) {}
await prefs.setString('location', 'Current Location');
}
Future<List<EventTypeModel>> _events_service_getEventTypesSafe() async {
try {
return await _eventsService.getEventTypes();
} catch (_) {
return <EventTypeModel>[];
}
}
Future<List<EventModel>> _events_service_getEventsSafe(String pincode) async {
try {
return await _eventsService.getEventsByPincode(pincode);
} catch (_) {
return <EventModel>[];
}
}
Future<void> _refresh() async {
await _loadUserDataAndEvents();
}
void _bookEventAtIndex(int index) {
if (index >= 0 && index < _events.length) {
setState(() => _events.removeAt(index));
}
}
Widget _categoryChip({
required String label,
required bool selected,
required VoidCallback onTap,
String? imageUrl,
IconData? icon,
}) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Container(
width: 110,
decoration: BoxDecoration(
color: selected ? theme.colorScheme.primary : Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected ? theme.colorScheme.primary : const Color(0xFFE5E7EB),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: selected
? theme.colorScheme.primary.withValues(alpha: 0.35)
: Colors.black.withValues(alpha: 0.08),
blurRadius: selected ? 12 : 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Image / Icon area
SizedBox(
height: 56,
width: 56,
child: imageUrl != null && imageUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 112,
memCacheHeight: 112,
fit: BoxFit.contain,
placeholder: (_, __) => Icon(
icon ?? Icons.category,
size: 36,
color: selected ? Colors.white.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.3),
),
errorWidget: (_, __, ___) => Icon(
icon ?? Icons.category,
size: 36,
color: selected ? Colors.white : theme.colorScheme.primary,
),
),
)
: Icon(
icon ?? Icons.category,
size: 36,
color: selected ? Colors.white : theme.colorScheme.primary,
),
),
const SizedBox(height: 10),
// Label
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: selected
? Colors.white
: theme.textTheme.bodyLarge?.color ?? Colors.black87,
),
),
),
],
),
),
);
}
Future<void> _openLocationSearch() async {
final selected = await Navigator.of(context).push(PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) => const SearchScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 220),
));
if (selected != null && selected is String) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('location', selected);
setState(() {
_location = selected;
});
await _refresh();
}
}
void _openEventSearch() {
final theme = Theme.of(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.95,
builder: (context, scrollController) {
String query = '';
List<EventModel> results = List.from(_events);
bool searching = false;
return StatefulBuilder(builder: (context, setModalState) {
// Instant client-side filter while typing
void _onQueryChanged(String v) {
query = v.trim().toLowerCase();
final r = _events.where((e) {
final title = (e.title ?? e.name ?? '').toLowerCase();
return title.contains(query);
}).toList();
setModalState(() {
results = r;
});
}
// Server-side search on submit (keyboard action / enter)
Future<void> _onSubmitted(String v) async {
final q = v.trim();
if (q.isEmpty) return;
setModalState(() => searching = true);
try {
final serverResults = await _eventsService.getEventsByPincode(_pincode, q: q);
setModalState(() {
results = serverResults;
searching = false;
});
} catch (_) {
setModalState(() => searching = false);
}
}
return Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 48,
height: 6,
decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(6)),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.dividerColor)),
child: Row(
children: [
Icon(Icons.search, color: theme.hintColor),
const SizedBox(width: 8),
Expanded(
child: TextField(
style: theme.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: 'Search events by name',
hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
border: InputBorder.none,
),
autofocus: true,
onChanged: _onQueryChanged,
textInputAction: TextInputAction.search,
onSubmitted: _onSubmitted,
),
)
],
),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.close, color: theme.iconTheme.color),
onPressed: () => Navigator.of(context).pop(),
)
],
),
const SizedBox(height: 12),
if (_loading || searching)
Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
else if (results.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: false,
physics: const ClampingScrollPhysics(),
itemBuilder: (ctx, idx) {
final ev = results[idx];
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
final title = ev.title ?? ev.name ?? '';
final subtitle = ev.startDate ?? '';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
leading: img != null && img.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: img,
memCacheWidth: 112,
memCacheHeight: 112,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 56, height: 56, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(width: 56, height: 56, color: theme.dividerColor, child: Icon(Icons.event, color: theme.hintColor)),
),
)
: Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
onTap: () {
Navigator.of(context).pop();
if (ev.id != null) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
}
},
);
},
separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
itemCount: results.length,
)), // ConstrainedBox
],
),
),
),
);
});
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Stack(
children: [
// IndexedStack keeps each tab alive and preserves state.
// RepaintBoundary isolates each tab so inactive tabs don't trigger repaints.
IndexedStack(
index: _selectedIndex,
children: [
RepaintBoundary(child: _buildHomeContent()), // index 0
const RepaintBoundary(child: CalendarScreen()), // index 1
const RepaintBoundary(child: ContributeScreen()), // index 2 (full page, scrollable)
const RepaintBoundary(child: ProfileScreen()), // index 3
],
),
// Floating bottom navigation (always visible)
// bottom offset accounts for home indicator on iPhone/Android gesture bar
Positioned(
left: 16,
right: 16,
bottom: MediaQuery.of(context).padding.bottom + 16,
child: _buildFloatingBottomNav(),
),
],
),
);
}
Widget _buildFloatingBottomNav() {
const activeColor = Color(0xFF2563EB);
const inactiveColor = Color(0xFF9CA3AF);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 16, offset: const Offset(0, -2)),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_bottomNavItem(0, Icons.home_outlined, Icons.home, 'Home'),
_bottomNavItem(1, Icons.calendar_today_outlined, Icons.calendar_today, 'Calendar'),
_bottomNavItem(2, Icons.front_hand_outlined, Icons.front_hand, 'Contribute'),
_bottomNavItem(3, Icons.person_outline, Icons.person, 'Profile'),
],
),
);
}
Widget _bottomNavItem(int index, IconData outlinedIcon, IconData filledIcon, String label) {
const activeColor = Color(0xFF2563EB);
const inactiveColor = Color(0xFF9CA3AF);
final active = _selectedIndex == index;
return GestureDetector(
onTap: () {
if (index == 2 && !AuthGuard.requireLogin(context, reason: 'Sign in to contribute events and earn rewards.')) return;
if (index == 3 && !AuthGuard.requireLogin(context, reason: 'Sign in to view your profile.')) return;
setState(() => _selectedIndex = index);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? filledIcon : outlinedIcon,
color: active ? activeColor : inactiveColor,
size: 24,
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: active ? activeColor : inactiveColor,
fontSize: 11,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
);
}
// Get hero events (first 4 events for the carousel)
List<EventModel> get _heroEvents => _events.take(6).toList();
String _formatDate(String dateStr) {
try {
final dt = DateTime.parse(dateStr);
return DateFormat('d MMM yyyy').format(dt);
} catch (_) {
return dateStr;
}
}
String _getEventTypeName(EventModel event) {
if (event.eventTypeId != null && event.eventTypeId! > 0) {
final match = _types.where((t) => t.id == event.eventTypeId);
if (match.isNotEmpty && match.first.name.isNotEmpty) {
return match.first.name.toUpperCase();
}
}
return 'EVENT';
}
// Date filter state
String _selectedDateFilter = '';
DateTime? _selectedCustomDate;
// Cached filtered events to avoid repeated DateTime.parse() calls
List<EventModel>? _cachedFilteredEvents;
String _cachedFilterKey = '';
// Cached event dates for calendar dots
Set<DateTime>? _cachedEventDates;
/// Returns all events filtered by date only (ignores category selection).
/// Used by Top Events and category sections so they always show all types.
List<EventModel> get _allFilteredByDate {
if (_selectedDateFilter.isEmpty) return _allEvents;
// Reuse the same date-filter logic as _filteredEvents but on _allEvents
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
DateTime filterStart;
DateTime filterEnd;
switch (_selectedDateFilter) {
case 'Today':
filterStart = today;
filterEnd = today;
break;
case 'Tomorrow':
filterStart = today.add(const Duration(days: 1));
filterEnd = filterStart;
break;
case 'This week':
filterStart = today;
filterEnd = today.add(Duration(days: 7 - today.weekday));
break;
case 'Date':
if (_selectedCustomDate == null) return _allEvents;
filterStart = DateTime(_selectedCustomDate!.year, _selectedCustomDate!.month, _selectedCustomDate!.day);
filterEnd = filterStart;
break;
default:
return _allEvents;
}
return _allEvents.where((e) {
try {
final s = DateTime.parse(e.startDate);
final eEnd = DateTime.parse(e.endDate);
final eStart = DateTime(s.year, s.month, s.day);
final eEndDay = DateTime(eEnd.year, eEnd.month, eEnd.day);
return !eEndDay.isBefore(filterStart) && !eStart.isAfter(filterEnd);
} catch (_) {
return false;
}
}).toList();
}
/// Returns the subset of [_events] that match the active date-filter chip.
/// Uses caching to avoid re-parsing dates on every access.
List<EventModel> get _filteredEvents {
if (_selectedDateFilter.isEmpty) return _events;
// Build a cache key from filter state
final cacheKey = '$_selectedDateFilter|$_selectedCustomDate|${_events.length}';
if (_cachedFilteredEvents != null && _cachedFilterKey == cacheKey) {
return _cachedFilteredEvents!;
}
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
DateTime filterStart;
DateTime filterEnd;
switch (_selectedDateFilter) {
case 'Today':
filterStart = today;
filterEnd = today;
break;
case 'Tomorrow':
final tomorrow = today.add(const Duration(days: 1));
filterStart = tomorrow;
filterEnd = tomorrow;
break;
case 'This week':
// MondaySunday of the current week
final weekday = today.weekday; // 1=Mon
filterStart = today.subtract(Duration(days: weekday - 1));
filterEnd = filterStart.add(const Duration(days: 6));
break;
case 'Date':
if (_selectedCustomDate == null) return _events;
filterStart = _selectedCustomDate!;
filterEnd = _selectedCustomDate!;
break;
default:
return _events;
}
_cachedFilteredEvents = _events.where((e) {
try {
final eStart = DateTime.parse(e.startDate);
final eEnd = DateTime.parse(e.endDate);
// Event overlaps with filter range
return !eEnd.isBefore(filterStart) && !eStart.isAfter(filterEnd);
} catch (_) {
return false;
}
}).toList();
_cachedFilterKey = cacheKey;
return _cachedFilteredEvents!;
}
Future<void> _onDateChipTap(String label) async {
if (label == 'Date') {
// Open custom calendar dialog
final picked = await _showCalendarBottomSheet();
if (picked != null) {
setState(() {
_selectedCustomDate = picked;
_selectedDateFilter = 'Date';
_cachedFilteredEvents = null; // invalidate cache
});
_showFilteredEventsSheet(
'${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}',
);
} else if (_selectedDateFilter == 'Date') {
setState(() {
_selectedDateFilter = '';
_selectedCustomDate = null;
_cachedFilteredEvents = null;
});
}
} else {
setState(() {
_selectedDateFilter = label;
_selectedCustomDate = null;
_cachedFilteredEvents = null; // invalidate cache
});
_showFilteredEventsSheet(label);
}
}
String _monthName(int m) {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return months[m - 1];
}
/// Shows a bottom sheet with events matching the current filter chip.
void _showFilteredEventsSheet(String title) {
final theme = Theme.of(context);
final filtered = _filteredEvents;
final count = filtered.length;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.55,
minChildSize: 0.3,
maxChildSize: 0.85,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFFEAEFFE), // lavender sheet bg matching web
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(3),
),
),
),
),
// Header row: title + close button
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12),
child: Row(
children: [
Expanded(
child: Text(
'$title ($count)',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
),
GestureDetector(
onTap: () {
Navigator.of(context).pop();
setState(() {
_selectedDateFilter = '';
_selectedCustomDate = null;
});
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, size: 18, color: Color(0xFF1A1A1A)),
),
),
],
),
),
// Events list
Expanded(
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'\u{1F3D7}\u{FE0F} No events scheduled for this period',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF6B7280),
),
),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
itemCount: filtered.length,
itemBuilder: (ctx, idx) {
final ev = filtered[idx];
return _buildSheetEventCard(ev, theme);
},
),
),
],
),
);
},
);
},
).whenComplete(() {
// Clear filter when sheet is dismissed
setState(() {
_selectedDateFilter = '';
_selectedCustomDate = null;
});
});
}
/// Builds an event card for the filter bottom sheet, matching web design.
Widget _buildSheetEventCard(EventModel ev, ThemeData theme) {
final title = ev.title ?? ev.name ?? '';
final dateLabel = ev.startDate ?? '';
final location = ev.place ?? 'Location';
final imageUrl = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty)
? ev.thumbImg!
: (ev.images.isNotEmpty ? ev.images.first.image : null);
Widget imageWidget;
if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) {
imageWidget = ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 160,
memCacheHeight: 160,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 80, height: 80,
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: 80, height: 80,
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.image, color: Colors.grey.shade400),
),
),
);
} else {
imageWidget = Container(
width: 80, height: 80,
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.image, color: Colors.grey.shade400),
);
}
return GestureDetector(
onTap: () {
Navigator.of(context).pop();
if (ev.id != null) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
imageWidget,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.calendar_today, size: 13, color: Colors.grey.shade500),
const SizedBox(width: 4),
Expanded(
child: Text(
dateLabel,
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Icon(Icons.location_on_outlined, size: 13, color: Colors.grey.shade500),
const SizedBox(width: 4),
Expanded(
child: Text(
location,
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
'Free',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
],
),
),
],
),
),
);
}
/// Collect all event dates (start + end range) to show dots on the calendar.
/// Cached to avoid re-parsing on every calendar open.
Set<DateTime> get _eventDates {
if (_cachedEventDates != null) return _cachedEventDates!;
final dates = <DateTime>{};
for (final e in _allEvents) {
try {
final start = DateTime.parse(e.startDate);
final end = DateTime.parse(e.endDate);
var d = DateTime(start.year, start.month, start.day);
final last = DateTime(end.year, end.month, end.day);
while (!d.isAfter(last)) {
dates.add(d);
d = d.add(const Duration(days: 1));
}
} catch (_) {}
}
_cachedEventDates = dates;
return dates;
}
/// Show a calendar bottom sheet with TableCalendar for date filtering.
Future<DateTime?> _showCalendarBottomSheet() {
DateTime focusedDay = _selectedCustomDate ?? DateTime.now();
DateTime? selectedDay = _selectedCustomDate;
final eventDates = _eventDates;
return showModalBottomSheet<DateTime>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return StatefulBuilder(builder: (ctx, setSheetState) {
final theme = Theme.of(ctx);
final isDark = theme.brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
// TableCalendar
TableCalendar(
firstDay: DateTime(2020),
lastDay: DateTime(2030),
focusedDay: focusedDay,
selectedDayPredicate: (day) =>
selectedDay != null && isSameDay(day, selectedDay),
onDaySelected: (selected, focused) {
setSheetState(() {
selectedDay = selected;
focusedDay = focused;
});
},
onPageChanged: (focused) {
setSheetState(() => focusedDay = focused);
},
eventLoader: (day) {
final normalized = DateTime(day.year, day.month, day.day);
return eventDates.contains(normalized) ? [true] : [];
},
startingDayOfWeek: StartingDayOfWeek.monday,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : const Color(0xFF1A1A2E),
),
leftChevronIcon: Icon(Icons.chevron_left,
color: isDark ? Colors.white70 : const Color(0xFF374151)),
rightChevronIcon: Icon(Icons.chevron_right,
color: isDark ? Colors.white70 : const Color(0xFF374151)),
),
calendarStyle: CalendarStyle(
selectedDecoration: const BoxDecoration(
color: Color(0xFF2563EB),
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF2563EB), width: 1.5),
),
todayTextStyle: const TextStyle(
color: Color(0xFF2563EB),
fontWeight: FontWeight.w700,
),
defaultTextStyle: TextStyle(
color: isDark ? Colors.white : const Color(0xFF374151),
),
weekendTextStyle: TextStyle(
color: isDark ? Colors.white70 : const Color(0xFF374151),
),
outsideTextStyle: TextStyle(
color: isDark ? Colors.white24 : Colors.grey[400]!,
),
markerDecoration: const BoxDecoration(
color: Color(0xFFEF4444),
shape: BoxShape.circle,
),
markerSize: 5,
markersMaxCount: 1,
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: TextStyle(
color: isDark ? Colors.white54 : Colors.grey[500],
fontWeight: FontWeight.w600,
fontSize: 13,
),
weekendStyle: TextStyle(
color: isDark ? Colors.white54 : Colors.grey[500],
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
const SizedBox(height: 16),
// Done button
SizedBox(
width: 160,
height: 48,
child: ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(selectedDay),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2563EB),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24)),
elevation: 0,
),
child: const Text('Done',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
),
),
],
),
),
);
});
},
);
}
Widget _buildHomeContent() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF1A0A2E), // deep purple
Color(0xFF16213E), // dark navy
Color(0xFF0A0A0A), // near black
],
stops: [0.0, 0.4, 0.8],
),
),
child: CustomScrollView(
slivers: [
// Hero section (dark bg)
SliverToBoxAdapter(child: _buildHeroSection()),
// White bottom section
SliverToBoxAdapter(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
),
child: _buildWhiteSection(),
),
),
],
),
);
}
Widget _buildHeroSection() {
return SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Top bar: location pill + search button
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: _openLocationSearch,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
const SizedBox(width: 6),
Text(
_location.length > 20 ? '${_location.substring(0, 20)}...' : _location,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, color: Colors.white, size: 18),
],
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const NotificationBell(),
const SizedBox(width: 8),
GestureDetector(
onTap: _openEventSearch,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
child: const Icon(Icons.search, color: Colors.white, size: 24),
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Featured carousel
_heroEvents.isEmpty
? _loading
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 320,
child: _HeroShimmer(),
),
)
: const SizedBox(
height: 280,
child: Center(
child: Text('No events available',
style: TextStyle(color: Colors.white70)),
),
)
: Column(
children: [
RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanDown: (_) => _autoScrollTimer?.cancel(),
onPanEnd: (_) => _startAutoScroll(delay: const Duration(seconds: 3)),
onPanCancel: () => _startAutoScroll(delay: const Duration(seconds: 3)),
child: SizedBox(
height: 320,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) {
_heroPageNotifier.value = page;
// 8s delay after manual swipe for full read time
_startAutoScroll(delay: const Duration(seconds: 8));
},
itemCount: _heroEvents.length,
itemBuilder: (context, index) {
// Scale animation: active card = 1.0, adjacent = 0.94
return AnimatedBuilder(
animation: _heroPageController,
builder: (context, child) {
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
if (_heroPageController.position.haveDimensions) {
scale = (1.0 -
(_heroPageController.page! - index).abs() * 0.06)
.clamp(0.94, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildHeroEventImage(_heroEvents[index]),
);
},
),
),
),
),
const SizedBox(height: 16),
// Pagination dots
_buildCarouselDots(),
],
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildCarouselDots() {
return ValueListenableBuilder<int>(
valueListenable: _heroPageNotifier,
builder: (context, currentPage, _) {
return SizedBox(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_heroEvents.isEmpty ? 5 : _heroEvents.length,
(i) {
final isActive = i == currentPage;
return GestureDetector(
onTap: () {
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(i,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
},
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? Colors.white
: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
),
),
),
);
},
),
),
);
},
);
}
/// Build a hero image card with the image only (rounded),
/// and the title text placed below the image.
Widget _buildHeroEventImage(EventModel event) {
String? img;
if (event.thumbImg != null && event.thumbImg!.isNotEmpty) {
img = event.thumbImg;
} else if (event.images.isNotEmpty && event.images.first.image.isNotEmpty) {
img = event.images.first.image;
}
const double radius = 24.0;
return GestureDetector(
onTap: () {
if (event.id != null) {
Navigator.push(context,
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Stack(
fit: StackFit.expand,
children: [
// ── Layer 0: Event image (full-bleed) ──
img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 700,
memCacheHeight: 400,
fit: BoxFit.cover,
placeholder: (_, __) => const _HeroShimmer(radius: radius),
errorWidget: (_, __, ___) =>
Container(decoration: AppDecoration.blueGradientRounded(radius)),
)
: Container(decoration: AppDecoration.blueGradientRounded(radius)),
// ── Layer 1: Bottom gradient overlay (text readability) ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.35, 1.0],
colors: [
Colors.transparent,
Colors.black.withOpacity(0.78),
],
),
),
),
),
// ── Layer 2: Event type glassmorphism badge (top-left) ──
Positioned(
top: 14,
left: 14,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded, color: Colors.amber, size: 13),
const SizedBox(width: 4),
Text(
_getEventTypeName(event),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
),
),
],
),
),
),
),
),
// ── Layer 3: Title + metadata (bottom overlay) ──
Positioned(
bottom: 18,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title ?? event.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1.25,
shadows: [
Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
],
),
),
const SizedBox(height: 8),
Row(
children: [
if (event.startDate != null) ...[
const Icon(Icons.calendar_today_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Text(
_formatDate(event.startDate!),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
const SizedBox(width: 10),
],
if (event.place != null && event.place!.isNotEmpty) ...[
const Icon(Icons.location_on_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Flexible(
child: Text(
event.place!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
],
],
),
],
),
),
],
),
),
),
);
}
Widget _buildWhiteSection() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search bar
GestureDetector(
onTap: _openEventSearch,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(28),
),
child: const Text(
'Search events, artists or attractions',
style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 14),
),
),
),
const SizedBox(height: 16),
// Date filter chips
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_dateChip(label: 'Date', icon: Icons.calendar_today, hasDropdown: true),
const SizedBox(width: 8),
_dateChip(label: 'Today'),
const SizedBox(width: 8),
_dateChip(label: 'Tomorrow'),
const SizedBox(width: 8),
_dateChip(label: 'This week'),
],
),
),
const SizedBox(height: 24),
// Top Events
const Text(
'Top Events',
style: TextStyle(
color: Color(0xFF111827),
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: _allFilteredByDate.isEmpty && _loading
? Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton())))
: _allFilteredByDate.isEmpty
? Center(child: Text(
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
style: const TextStyle(color: Color(0xFF9CA3AF)),
))
: PageView.builder(
controller: PageController(viewportFraction: 0.85),
physics: const PageScrollPhysics(),
itemCount: _allFilteredByDate.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildTopEventCard(_allFilteredByDate[index]),
),
),
),
const SizedBox(height: 24),
// Events Around You
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Events Around You',
style: TextStyle(
color: Color(0xFF111827),
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () => setState(() => _categoriesExpanded = !_categoriesExpanded),
child: Text(
_categoriesExpanded ? 'Show Less' : 'View All',
style: const TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 12),
// Category chips — horizontal scroll (collapsed) or wrap grid (expanded)
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: _categoriesExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
// Collapsed: horizontal scroll
firstChild: SizedBox(
height: 140,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_categoryChip(
label: 'All Events',
icon: Icons.grid_view_rounded,
selected: _selectedTypeId == -1,
onTap: () => _onSelectType(-1),
),
const SizedBox(width: 12),
for (final t in _types) ...[
_categoryChip(
label: t.name,
imageUrl: t.iconUrl,
icon: _getIconForType(t.name),
selected: _selectedTypeId == t.id,
onTap: () => _onSelectType(t.id),
),
const SizedBox(width: 12),
],
],
),
),
// Expanded: wrap grid showing all categories
secondChild: Wrap(
spacing: 10,
runSpacing: 10,
children: [
_categoryChip(
label: 'All Events',
icon: Icons.grid_view_rounded,
selected: _selectedTypeId == -1,
onTap: () => _onSelectType(-1),
),
for (final t in _types)
_categoryChip(
label: t.name,
imageUrl: t.iconUrl,
icon: _getIconForType(t.name),
selected: _selectedTypeId == t.id,
onTap: () => _onSelectType(t.id),
),
],
),
),
const SizedBox(height: 16),
// Event sections — shelves (All) or filtered list (specific category)
if (_loading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(bottom: 8), child: EventListSkeleton()))),
)
else if (_allFilteredByDate.isEmpty && _selectedDateFilter.isNotEmpty)
Padding(
padding: const EdgeInsets.all(40),
child: Center(child: Text(
'No events for "$_selectedDateFilter"',
style: const TextStyle(color: Color(0xFF9CA3AF)),
)),
)
else
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _selectedTypeId == -1
// "All Events" — show category shelf sections
? Column(
key: const ValueKey('shelves'),
children: [
for (final t in _types)
if (_allFilteredByDate.where((e) => e.eventTypeId == t.id).isNotEmpty) ...[
_buildTypeSection(t),
const SizedBox(height: 18),
],
],
)
// Specific category — show vertical list of events for that category only
: Column(
key: ValueKey('filtered_$_selectedTypeId'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category name header
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Expanded(
child: Text(
_types.firstWhere((t) => t.id == _selectedTypeId, orElse: () => _types.first).name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF111827)),
),
),
Text(
'${_allFilteredByDate.where((e) => e.eventTypeId == _selectedTypeId).length} events',
style: const TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)),
),
],
),
),
// Event cards
for (final e in _allFilteredByDate.where((e) => e.eventTypeId == _selectedTypeId))
_buildFullWidthCard(e),
if (_allFilteredByDate.where((e) => e.eventTypeId == _selectedTypeId).isEmpty)
const Padding(
padding: EdgeInsets.all(40),
child: Center(child: Text('No events in this category', style: TextStyle(color: Color(0xFF9CA3AF)))),
),
],
),
),
// Bottom padding for nav bar
const SizedBox(height: 100),
],
),
);
}
Widget _dateChip({required String label, IconData? icon, bool hasDropdown = false}) {
final isSelected = _selectedDateFilter == label;
// For 'Date' chip, show the picked date instead of just "Date"
String displayLabel = label;
if (label == 'Date' && isSelected && _selectedCustomDate != null) {
final d = _selectedCustomDate!;
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
displayLabel = '${d.day} ${months[d.month - 1]}';
}
return GestureDetector(
onTap: () => _onDateChipTap(label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? const Color(0xFF2563EB) : const Color(0xFFE5E7EB)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF374151)),
const SizedBox(width: 6),
],
Text(
displayLabel,
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFF374151),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
if (hasDropdown) ...[
const SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down, size: 16, color: isSelected ? Colors.white : const Color(0xFF374151)),
],
],
),
),
);
}
Widget _buildTopEventCard(EventModel event) {
String? img;
if (event.thumbImg != null && event.thumbImg!.isNotEmpty) {
img = event.thumbImg;
} else if (event.images.isNotEmpty && event.images.first.image.isNotEmpty) {
img = event.images.first.image;
}
return GestureDetector(
onTap: () {
if (event.id != null) {
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
}
},
child: Container(
width: 150,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
// Background image
img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 300,
memCacheHeight: 200,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => Container(
color: const Color(0xFF374151),
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white38))),
),
errorWidget: (_, __, ___) => Container(
color: const Color(0xFF374151),
child: const Icon(Icons.image, color: Colors.white38, size: 40),
),
)
: Container(
color: const Color(0xFF374151),
child: const Icon(Icons.image, color: Colors.white38, size: 40),
),
// Dark gradient overlay at bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
),
),
),
),
// Title text at bottom left
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Text(
event.title ?? event.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
),
],
),
),
);
}
/// Build a type section that follows your requested layout rules:
/// - If type has <= 5 events => single horizontal row of compact cards.
/// - If type has >= 6 events => arrange events into column groups of 3 (so visually there are 3 rows across horizontally scrollable columns).
Widget _buildTypeSection(EventTypeModel type) {
final theme = Theme.of(context);
final eventsForType = _allFilteredByDate.where((e) => e.eventTypeId == type.id).toList();
final n = eventsForType.length;
// Header row
Widget header = Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(type.name, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
_onSelectType(type.id);
},
child: Text('View All', style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
),
],
),
);
// If <= 5 events: show one horizontal row using _buildHorizontalEventCard
if (n <= 5) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
header,
const SizedBox(height: 8),
SizedBox(
height: 290, // card height: image 180 + text ~110
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (ctx, idx) => _buildHorizontalEventCard(eventsForType[idx]),
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemCount: eventsForType.length,
),
),
],
);
}
// For 6+ events: arrange into columns where each column has up to 3 stacked cards.
final columnsCount = (n / 3).ceil();
final columnWidth = 260.0; // narrower so second column peeks in
final verticalCardHeight = 120.0; // each stacked card height matches sample
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
header,
const SizedBox(height: 8),
// Container height must accommodate 3 stacked cards + small gaps
SizedBox(
height: (verticalCardHeight * 3) + 16, // 3 cards + spacing
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (ctx, colIndex) {
// Build one column: contains up to 3 items: indices colIndex*3 + 0/1/2
return Container(
width: columnWidth,
child: Column(
children: [
// top card
if ((colIndex * 3 + 0) < n)
SizedBox(
height: verticalCardHeight,
child: _buildStackedCard(eventsForType[colIndex * 3 + 0]),
)
else
const SizedBox(height: 0),
const SizedBox(height: 8),
// middle card
if ((colIndex * 3 + 1) < n)
SizedBox(
height: verticalCardHeight,
child: _buildStackedCard(eventsForType[colIndex * 3 + 1]),
)
else
const SizedBox(height: 0),
const SizedBox(height: 8),
// bottom card
if ((colIndex * 3 + 2) < n)
SizedBox(
height: verticalCardHeight,
child: _buildStackedCard(eventsForType[colIndex * 3 + 2]),
)
else
const SizedBox(height: 0),
],
),
);
},
itemCount: columnsCount,
),
),
],
);
}
/// A stacked card styled to match your sample (left square thumbnail, bold title).
/// REMOVED: price/rating row (per your request).
Widget _buildStackedCard(EventModel e) {
final theme = Theme.of(context);
String? img;
if (e.thumbImg != null && e.thumbImg!.isNotEmpty) {
img = e.thumbImg;
} else if (e.images.isNotEmpty && e.images.first.image.isNotEmpty) {
img = e.images.first.image;
}
return GestureDetector(
onTap: () {
if (e.id != null) Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e)));
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 0),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: theme.shadowColor.withOpacity(0.06), blurRadius: 12, offset: const Offset(0, 8))],
),
padding: const EdgeInsets.all(12),
child: Row(
children: [
// thumbnail square (rounded)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 192,
memCacheHeight: 192,
width: 96,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 96, color: theme.dividerColor, child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
errorWidget: (_, __, ___) => Container(width: 96, color: theme.dividerColor),
)
: Container(width: 96, height: double.infinity, color: theme.dividerColor),
),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [
Text(e.title ?? e.name ?? '', maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, fontSize: 18)),
// removed price/rating row here per request
]),
),
// optional heart icon aligned top-right
Icon(Icons.favorite_border, color: theme.hintColor),
],
),
),
);
}
/// Compact card used inside the one-row layout for small counts (<=5).
/// Matches Figma: vertical card with image, date badge, title, location, "Free".
Widget _buildHorizontalEventCard(EventModel e) {
final theme = Theme.of(context);
String? img;
if (e.thumbImg != null && e.thumbImg!.isNotEmpty) {
img = e.thumbImg;
} else if (e.images.isNotEmpty && e.images.first.image.isNotEmpty) {
img = e.images.first.image;
}
// Parse day & month for the date badge
String day = '';
String month = '';
try {
final parts = e.startDate.split('-');
if (parts.length == 3) {
day = int.parse(parts[2]).toString();
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
month = months[int.parse(parts[1]) - 1];
}
} catch (_) {}
final venue = e.venueName ?? e.place ?? '';
return GestureDetector(
onTap: () {
if (e.id != null) Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e)));
},
child: SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image with date badge
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 440,
memCacheHeight: 360,
width: 220,
height: 180,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 220,
height: 180,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(18),
),
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: 220,
height: 180,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(18),
),
child: Icon(Icons.image, size: 40, color: theme.hintColor),
),
)
: Container(
width: 220,
height: 180,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(18),
),
child: Icon(Icons.image, size: 40, color: theme.hintColor),
),
),
// Date badge
if (day.isNotEmpty)
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
day,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Colors.black87,
height: 1.1,
),
),
Text(
month,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.black54,
height: 1.2,
),
),
],
),
),
),
],
),
const SizedBox(height: 10),
// Title
Text(
e.title ?? e.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (venue.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
venue,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 13,
),
),
],
const SizedBox(height: 4),
Text(
'Free',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
],
),
),
);
}
/// Format a date string (YYYY-MM-DD) to short display like "4 Mar".
String _formatDateShort(String dateStr) {
try {
final parts = dateStr.split('-');
if (parts.length == 3) {
final day = int.parse(parts[2]);
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
final month = months[int.parse(parts[1]) - 1];
return '$day $month';
}
} catch (_) {}
return dateStr;
}
/// Full width card used when a single type is selected (vertical list).
/// Matches Figma: large image, badge, title, date + venue.
Widget _buildFullWidthCard(EventModel e) {
final theme = Theme.of(context);
String? img;
if (e.thumbImg != null && e.thumbImg!.isNotEmpty) {
img = e.thumbImg;
} else if (e.images.isNotEmpty && e.images.first.image.isNotEmpty) {
img = e.images.first.image;
}
// Build date range string
final startShort = _formatDateShort(e.startDate);
final endShort = _formatDateShort(e.endDate);
final dateRange = startShort == endShort ? startShort : '$startShort - $endShort';
final venue = e.venueName ?? e.place ?? '';
return GestureDetector(
onTap: () {
if (e.id != null) Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e)));
},
child: Container(
margin: const EdgeInsets.only(bottom: 18),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: theme.shadowColor.withOpacity(0.10), blurRadius: 16, offset: const Offset(0, 6)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image with badge
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 800,
memCacheHeight: 400,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: const Center(child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Icon(Icons.image, size: 48, color: theme.hintColor),
),
)
: Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Icon(Icons.image, size: 48, color: theme.hintColor),
),
),
// "ADDED BY EVENTIFY" badge
Positioned(
top: 14,
left: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.star, color: Colors.white, size: 14),
SizedBox(width: 4),
Text(
'ADDED BY EVENTIFY',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
],
),
),
),
],
),
// Title + date/venue
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.title ?? e.name ?? '',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.calendar_today_outlined, size: 14, color: theme.hintColor),
const SizedBox(width: 4),
Text(
dateRange,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 13,
),
),
if (venue.isNotEmpty) ...[
const SizedBox(width: 12),
Icon(Icons.location_on_outlined, size: 14, color: theme.hintColor),
const SizedBox(width: 3),
Expanded(
child: Text(
venue,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
),
),
],
),
),
);
}
IconData _getIconForType(String typeName) {
final name = typeName.toLowerCase();
if (name.contains('music')) return Icons.music_note;
if (name.contains('art') || name.contains('comedy')) return Icons.palette;
if (name.contains('festival')) return Icons.celebration;
if (name.contains('heritage') || name.contains('history')) return Icons.account_balance;
if (name.contains('sport')) return Icons.sports;
if (name.contains('food')) return Icons.restaurant;
return Icons.event;
}
void _onSelectType(int id) {
setState(() {
_selectedTypeId = id;
_events = id == -1 ? List.from(_allEvents) : _allEvents.where((e) => e.eventTypeId == id).toList();
_cachedFilteredEvents = null;
});
}
String _getShortEmailLabel() {
try {
final parts = _username.split('@');
if (parts.isNotEmpty && parts[0].isNotEmpty) return parts[0];
} catch (_) {}
return 'You';
}
}
/// Animated shimmer placeholder shown while a hero card image is loading.
/// Renders a blue-toned scan-line effect matching the app's colour palette.
class _HeroShimmer extends StatefulWidget {
final double radius;
const _HeroShimmer({this.radius = 24.0});
@override
State<_HeroShimmer> createState() => _HeroShimmerState();
}
class _HeroShimmerState extends State<_HeroShimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, __) {
final x = -1.5 + _ctrl.value * 3.0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius),
gradient: LinearGradient(
begin: Alignment(x - 1.0, 0),
end: Alignment(x, 0),
colors: const [
Color(0xFF1A2A4A),
Color(0xFF2D4580),
Color(0xFF1A2A4A),
],
),
),
);
},
);
}
}