Backend: Rewrote EventListAPI to query per-type with DB-level LIMIT instead of loading all 734 events into memory. Added slim serializer (32KB vs 154KB). Added DB indexes on event_type_id and pincode. Frontend: Category chips now filter locally from _allEvents (instant, no API call). Top Events and category sections always show all types regardless of selected category. Added TTL caching for event types (30min) and events (5min). Reduced API timeout from 30s to 10s. Added memCacheHeight to all CachedNetworkImage widgets. Batched setState calls from 5 to 2 during startup. Cached _eventDates getter. Switched baseUrl to em.eventifyplus.com (Django via Nginx+SSL). Added initialEvent param to LearnMoreScreen for instant detail views. Resolved relative media URLs for category icons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2356 lines
89 KiB
Dart
2356 lines
89 KiB
Dart
// lib/screens/home_screen.dart
|
||
import 'dart:async';
|
||
import 'dart:ui';
|
||
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 '../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';
|
||
|
||
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 _loading = true;
|
||
|
||
// Hero carousel
|
||
final PageController _heroPageController = PageController(viewportFraction: 0.88);
|
||
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(e.toString())));
|
||
}
|
||
}
|
||
}
|
||
|
||
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),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: selected
|
||
? theme.colorScheme.primary.withOpacity(0.35)
|
||
: Colors.black.withOpacity(0.06),
|
||
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);
|
||
return StatefulBuilder(builder: (context, setModalState) {
|
||
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;
|
||
});
|
||
}
|
||
|
||
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: (v) => _onQueryChanged(v),
|
||
),
|
||
)
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton(
|
||
icon: Icon(Icons.close, color: theme.iconTheme.color),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
)
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (_loading)
|
||
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
|
||
RepaintBoundary(
|
||
child: ChangeNotifierProvider(
|
||
create: (_) => GamificationProvider(),
|
||
child: const ContributeScreen(),
|
||
),
|
||
), // index 2 (full page, scrollable)
|
||
const RepaintBoundary(child: ProfileScreen()), // index 3
|
||
],
|
||
),
|
||
|
||
// Floating bottom navigation (always visible)
|
||
Positioned(
|
||
left: 16,
|
||
right: 16,
|
||
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':
|
||
// Monday–Sunday 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 _showCalendarDialog();
|
||
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 custom calendar dialog matching the app design.
|
||
Future<DateTime?> _showCalendarDialog() {
|
||
DateTime viewMonth = _selectedCustomDate ?? DateTime.now();
|
||
DateTime? selected = _selectedCustomDate;
|
||
final eventDates = _eventDates;
|
||
|
||
return showDialog<DateTime>(
|
||
context: context,
|
||
barrierColor: Colors.black54,
|
||
builder: (ctx) {
|
||
return StatefulBuilder(builder: (ctx, setDialogState) {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
|
||
// Calendar calculations
|
||
final firstDayOfMonth = DateTime(viewMonth.year, viewMonth.month, 1);
|
||
final daysInMonth = DateTime(viewMonth.year, viewMonth.month + 1, 0).day;
|
||
final startWeekday = firstDayOfMonth.weekday; // 1=Mon
|
||
|
||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||
const dayHeaders = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||
|
||
return Center(
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 28),
|
||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Month navigation
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_calendarNavButton(Icons.chevron_left, () {
|
||
setDialogState(() {
|
||
viewMonth = DateTime(viewMonth.year, viewMonth.month - 1, 1);
|
||
});
|
||
}),
|
||
Text(
|
||
'${months[viewMonth.month - 1]} ${viewMonth.year}',
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)),
|
||
),
|
||
_calendarNavButton(Icons.chevron_right, () {
|
||
setDialogState(() {
|
||
viewMonth = DateTime(viewMonth.year, viewMonth.month + 1, 1);
|
||
});
|
||
}),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Day of week headers
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||
children: dayHeaders.map((d) => SizedBox(
|
||
width: 36,
|
||
child: Center(child: Text(d, style: TextStyle(color: Colors.grey[500], fontWeight: FontWeight.w600, fontSize: 13))),
|
||
)).toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// Calendar grid
|
||
...List.generate(6, (weekRow) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||
children: List.generate(7, (weekCol) {
|
||
final cellIndex = weekRow * 7 + weekCol;
|
||
final dayNum = cellIndex - (startWeekday - 1) + 1;
|
||
|
||
if (dayNum < 1 || dayNum > daysInMonth) {
|
||
return const SizedBox(width: 36, height: 44);
|
||
}
|
||
|
||
final cellDate = DateTime(viewMonth.year, viewMonth.month, dayNum);
|
||
final isToday = cellDate == today;
|
||
final isSelected = selected != null &&
|
||
cellDate.year == selected!.year &&
|
||
cellDate.month == selected!.month &&
|
||
cellDate.day == selected!.day;
|
||
final hasEvent = eventDates.contains(cellDate);
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
setDialogState(() => selected = cellDate);
|
||
},
|
||
child: SizedBox(
|
||
width: 36,
|
||
height: 44,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? const Color(0xFF2563EB)
|
||
: Colors.transparent,
|
||
shape: BoxShape.circle,
|
||
border: isToday && !isSelected
|
||
? Border.all(color: const Color(0xFF2563EB), width: 1.5)
|
||
: null,
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
'$dayNum',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: (isToday || isSelected) ? FontWeight.w700 : FontWeight.w500,
|
||
color: isSelected
|
||
? Colors.white
|
||
: isToday
|
||
? const Color(0xFF2563EB)
|
||
: const Color(0xFF374151),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Event dot
|
||
if (hasEvent)
|
||
Container(
|
||
width: 5,
|
||
height: 5,
|
||
decoration: BoxDecoration(
|
||
color: isSelected ? Colors.white : const Color(0xFFEF4444),
|
||
shape: BoxShape.circle,
|
||
),
|
||
)
|
||
else
|
||
const SizedBox(height: 5),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
}),
|
||
|
||
const SizedBox(height: 12),
|
||
|
||
// Done button
|
||
SizedBox(
|
||
width: 160,
|
||
height: 48,
|
||
child: ElevatedButton(
|
||
onPressed: () => Navigator.of(ctx).pop(selected),
|
||
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 _calendarNavButton(IconData icon, VoidCallback onTap) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.grey[300]!),
|
||
),
|
||
child: Icon(icon, color: const Color(0xFF374151), size: 22),
|
||
),
|
||
);
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
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: 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
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _allFilteredByDate.isEmpty
|
||
? Center(child: Text(
|
||
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
|
||
style: const TextStyle(color: Color(0xFF9CA3AF)),
|
||
))
|
||
: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: _allFilteredByDate.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||
itemBuilder: (context, index) => _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: () {},
|
||
child: const Text(
|
||
'View All',
|
||
style: TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// Category chips (card-style)
|
||
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),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Event sections by type — always show ALL categories
|
||
if (_loading)
|
||
const Padding(
|
||
padding: EdgeInsets.all(40),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
)
|
||
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
|
||
Column(
|
||
children: [
|
||
for (final t in _types)
|
||
if (_allFilteredByDate.where((e) => e.eventTypeId == t.id).isNotEmpty) ...[
|
||
_buildTypeSection(t),
|
||
const SizedBox(height: 18),
|
||
],
|
||
],
|
||
),
|
||
|
||
// 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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|