Files
Eventify-frontend/lib/screens/home_screen.dart
Sicherhaven 50caad21a5 release: bump version to 1.4(p) (versionCode 14)
- Update versionCode 12 → 14, versionName 1.3(p) → 1.4(p)
- Update pubspec.yaml version to 1.4.0+14
- Add CHANGELOG.md with full version history
- Update README.md: version badge + changelog section
- Desktop Contribute Dashboard rebuilt to match web version
  - Contributor Dashboard title, 3-tab nav (Contribute/Leaderboard/Achievements)
  - Two-column submit form, tier milestone progress bar
  - Desktop leaderboard with podium, filters, rank table (green points)
  - Desktop achievements 3-column badge grid
  - Inline Reward Shop with RP balance
- Gamification feature module (EP, RP, leaderboard, achievements, shop)
- Profile screen redesigned to match web app layout with animations
- Home screen bottom sheet date filter chips
- Updated API endpoints, login/event detail screens, theme colors
- Added Gilroy font suite, responsive layout improvements
2026-03-18 11:10:56 +05:30

2046 lines
76 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/screens/home_screen.dart
import 'dart:async';
import 'dart:ui';
import '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();
int _heroCurrentPage = 0;
Timer? _autoScrollTimer;
@override
void initState() {
super.initState();
_loadUserDataAndEvents();
_startAutoScroll();
}
@override
void dispose() {
_autoScrollTimer?.cancel();
_heroPageController.dispose();
super.dispose();
}
void _startAutoScroll() {
_autoScrollTimer?.cancel();
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
if (_heroEvents.isEmpty) return;
final nextPage = (_heroCurrentPage + 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
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
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: Image.network(img, width: 56, height: 56, fit: BoxFit.cover))
: 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,
),
],
),
),
),
);
});
},
);
},
);
}
@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.
IndexedStack(
index: _selectedIndex,
children: [
_buildHomeContent(), // index 0
const CalendarScreen(), // index 1
ChangeNotifierProvider(
create: (_) => GamificationProvider(),
child: const ContributeScreen(),
), // index 2 (full page, scrollable)
const 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':
// MondaySunday of the current week
final weekday = today.weekday; // 1=Mon
filterStart = today.subtract(Duration(days: weekday - 1));
filterEnd = filterStart.add(const Duration(days: 6));
break;
case 'Date':
if (_selectedCustomDate == null) return _events;
filterStart = _selectedCustomDate!;
filterEnd = _selectedCustomDate!;
break;
default:
return _events;
}
_cachedFilteredEvents = _events.where((e) {
try {
final eStart = DateTime.parse(e.startDate);
final eEnd = DateTime.parse(e.endDate);
// Event overlaps with filter range
return !eEnd.isBefore(filterStart) && !eStart.isAfter(filterEnd);
} catch (_) {
return false;
}
}).toList();
_cachedFilterKey = cacheKey;
return _cachedFilteredEvents!;
}
Future<void> _onDateChipTap(String label) async {
if (label == 'Date') {
// Open custom calendar dialog
final picked = await _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: 300,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) => setState(() => _heroCurrentPage = page),
itemCount: _heroEvents.length,
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
),
),
const SizedBox(height: 16),
// Pagination dots
_buildCarouselDots(),
],
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildCarouselDots() {
return SizedBox(
height: 12,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_heroEvents.isEmpty ? 5 : _heroEvents.length,
(i) {
final isActive = i == _heroCurrentPage;
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;
}
final 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: 24),
child: Column(
children: [
// Image only (no text overlay)
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: SizedBox(
width: double.infinity,
child: img != null && img.isNotEmpty
? Image.network(
img,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(decoration: AppDecoration.blueGradientRounded(radius)),
)
: Container(
decoration: AppDecoration.blueGradientRounded(radius),
),
),
),
),
// Title text outside the image
const SizedBox(height: 12),
Text(
event.title ?? event.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
height: 1.2,
shadows: [
Shadow(color: Colors.black38, blurRadius: 6, offset: Offset(0, 2)),
],
),
),
],
),
),
);
}
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';
}
}