UI/UX Pro Max + Flutter Expert audit of the home screen hero section. viewportFraction 0.88 Adjacent cards peek 6% on each side — users see there is more content to swipe without any instruction. Most impactful single-line UX change. Overlay card design Title and metadata (date + location) now live ON the image behind a dark gradient (transparent → black 78%) at the bottom 65% of the card. Previously the title was below the image in a split layout that wasted space and felt disconnected. Card height increased 300 → 320px. FEATURED glassmorphism badge Top-left corner chip with BackdropFilter blur (sigmaX/Y 10) and a white-border container gives each card a premium editorial feel. Scale animation (AnimatedBuilder per card) Active card scales to 1.0, adjacent cards to 0.94. The AnimatedBuilder is placed inside itemBuilder so only the visible card rebuilds on each scroll tick — not the PageView or any ancestor. Auto-scroll resets on page change onPageChanged now calls _startAutoScroll() which cancels the previous timer and starts a fresh 3-second countdown. Users who swipe manually always get a full 3 seconds to read before auto-advance continues. Shimmer loading placeholder (_HeroShimmer) New StatefulWidget added below HomeScreen — a LinearGradient scan-line animated at 1400ms repeat. Replaces the flat Color(0xFF1A2A4A) box that looked broken while images were loading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2232 lines
84 KiB
Dart
2232 lines
84 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 'package:cached_network_image/cached_network_image.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: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> _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() {
|
||
_autoScrollTimer?.cancel();
|
||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||
if (_heroEvents.isEmpty) return;
|
||
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
|
||
if (_heroPageController.hasClients) {
|
||
_heroPageController.animateToPage(
|
||
nextPage,
|
||
duration: const Duration(milliseconds: 500),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _loadUserDataAndEvents() async {
|
||
setState(() => _loading = true);
|
||
final prefs = await SharedPreferences.getInstance();
|
||
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? '';
|
||
_location = prefs.getString('location') ?? 'Whitefield, Bengaluru';
|
||
_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;
|
||
_events = events;
|
||
_selectedTypeId = -1;
|
||
_cachedFilteredEvents = null; // invalidate cache
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
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,
|
||
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,
|
||
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)));
|
||
}
|
||
},
|
||
);
|
||
},
|
||
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: () => 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(4).toList();
|
||
|
||
// Date filter state
|
||
String _selectedDateFilter = '';
|
||
DateTime? _selectedCustomDate;
|
||
|
||
// Cached filtered events to avoid repeated DateTime.parse() calls
|
||
List<EventModel>? _cachedFilteredEvents;
|
||
String _cachedFilterKey = '';
|
||
|
||
/// 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,
|
||
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)));
|
||
}
|
||
},
|
||
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.
|
||
Set<DateTime> get _eventDates {
|
||
final dates = <DateTime>{};
|
||
for (final e in _events) {
|
||
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 (_) {}
|
||
}
|
||
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
|
||
? SizedBox(
|
||
height: 280,
|
||
child: Center(
|
||
child: _loading
|
||
? const CircularProgressIndicator(color: Colors.white)
|
||
: const Text('No events available', style: TextStyle(color: Colors.white70)),
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
SizedBox(
|
||
height: 320,
|
||
child: PageView.builder(
|
||
controller: _heroPageController,
|
||
onPageChanged: (page) {
|
||
_heroPageNotifier.value = page;
|
||
// Reset 3-second countdown so user always gets full read time
|
||
_startAutoScroll();
|
||
},
|
||
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: 12,
|
||
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: Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
width: isActive ? 24 : 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: isActive ? Colors.white : Colors.white.withOpacity(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)));
|
||
}
|
||
},
|
||
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,
|
||
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: FEATURED 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.withOpacity(0.18),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: Colors.white.withOpacity(0.28)),
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.star_rounded, color: Colors.amber, size: 13),
|
||
SizedBox(width: 4),
|
||
Text(
|
||
'FEATURED',
|
||
style: 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(
|
||
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: _filteredEvents.isEmpty && _loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _filteredEvents.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: _filteredEvents.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||
itemBuilder: (context, index) => _buildTopEventCard(_filteredEvents[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
|
||
if (_selectedTypeId == -1) ...[
|
||
if (_loading)
|
||
const Padding(
|
||
padding: EdgeInsets.all(40),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
)
|
||
else if (_filteredEvents.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 (_filteredEvents.where((e) => e.eventTypeId == t.id).isNotEmpty) ...[
|
||
_buildTypeSection(t),
|
||
const SizedBox(height: 18),
|
||
],
|
||
],
|
||
),
|
||
] else ...[
|
||
if (_loading)
|
||
const Padding(
|
||
padding: EdgeInsets.all(40),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
)
|
||
else
|
||
Column(
|
||
children: _filteredEvents.map((e) => _buildFullWidthCard(e)).toList(),
|
||
),
|
||
],
|
||
|
||
// 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)));
|
||
}
|
||
},
|
||
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,
|
||
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 = _filteredEvents.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)));
|
||
},
|
||
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,
|
||
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)));
|
||
},
|
||
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,
|
||
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)));
|
||
},
|
||
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,
|
||
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) async {
|
||
setState(() {
|
||
_selectedTypeId = id;
|
||
});
|
||
|
||
try {
|
||
final all = await _eventsService.getEventsByPincode(_pincode);
|
||
final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList();
|
||
if (mounted) setState(() { _events = filtered; _cachedFilteredEvents = null; });
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||
}
|
||
}
|
||
|
||
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({required this.radius});
|
||
|
||
@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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|