Backend: Rewrote EventListAPI to query per-type with DB-level LIMIT instead of loading all 734 events into memory. Added slim serializer (32KB vs 154KB). Added DB indexes on event_type_id and pincode. Frontend: Category chips now filter locally from _allEvents (instant, no API call). Top Events and category sections always show all types regardless of selected category. Added TTL caching for event types (30min) and events (5min). Reduced API timeout from 30s to 10s. Added memCacheHeight to all CachedNetworkImage widgets. Batched setState calls from 5 to 2 during startup. Cached _eventDates getter. Switched baseUrl to em.eventifyplus.com (Django via Nginx+SSL). Added initialEvent param to LearnMoreScreen for instant detail views. Resolved relative media URLs for category icons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
892 lines
32 KiB
Dart
892 lines
32 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../features/events/models/event_models.dart';
|
|
import '../features/events/services/events_service.dart';
|
|
import '../features/gamification/providers/gamification_provider.dart';
|
|
import '../widgets/responsive_shell.dart';
|
|
import 'calendar_screen.dart';
|
|
import 'profile_screen.dart';
|
|
import 'booking_screen.dart';
|
|
import 'settings_screen.dart';
|
|
import 'learn_more_screen.dart';
|
|
import 'contribute_screen.dart';
|
|
|
|
class HomeDesktopScreen extends StatefulWidget {
|
|
final bool skipSidebarEntranceAnimation;
|
|
const HomeDesktopScreen({Key? key, this.skipSidebarEntranceAnimation = false})
|
|
: super(key: key);
|
|
|
|
@override
|
|
State<HomeDesktopScreen> createState() => _HomeDesktopScreenState();
|
|
}
|
|
|
|
class _HomeDesktopScreenState extends State<HomeDesktopScreen> {
|
|
int _selectedMenu = 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ResponsiveShell(
|
|
currentIndex: _selectedMenu,
|
|
onIndexChanged: (idx) => setState(() => _selectedMenu = idx),
|
|
showTopBar: true,
|
|
child: _getCurrentPage(),
|
|
);
|
|
}
|
|
|
|
Widget _getCurrentPage() {
|
|
switch (_selectedMenu) {
|
|
case 1:
|
|
return const CalendarScreen();
|
|
case 2:
|
|
return const ProfileScreen();
|
|
case 3:
|
|
return BookingScreen(onBook: () {}, image: '');
|
|
case 4:
|
|
return ChangeNotifierProvider(
|
|
create: (_) => GamificationProvider(),
|
|
child: const ContributeScreen(),
|
|
);
|
|
case 5:
|
|
return const SettingsScreen();
|
|
default:
|
|
return _HomeContent(
|
|
onEventTap: (eventId, event) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: eventId, initialEvent: event)),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Home content — hero, categories, event grid
|
|
// ---------------------------------------------------------------------------
|
|
class _HomeContent extends StatefulWidget {
|
|
final void Function(int eventId, EventModel event) onEventTap;
|
|
const _HomeContent({required this.onEventTap});
|
|
|
|
@override
|
|
State<_HomeContent> createState() => _HomeContentState();
|
|
}
|
|
|
|
class _HomeContentState extends State<_HomeContent>
|
|
with SingleTickerProviderStateMixin {
|
|
final EventsService _eventsService = EventsService();
|
|
|
|
List<EventModel> _events = [];
|
|
List<EventTypeModel> _types = [];
|
|
bool _loading = true;
|
|
bool _loadingTypes = true;
|
|
|
|
int _selectedTypeId = -1;
|
|
String? _selectedTypeLabel;
|
|
|
|
String _username = 'Guest';
|
|
String _pincode = 'all';
|
|
|
|
// Ken Burns hero animation
|
|
late AnimationController _heroAnimController;
|
|
late Animation<double> _heroScaleAnim;
|
|
int _heroImageIndex = 0;
|
|
Timer? _heroRotateTimer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_heroAnimController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 12),
|
|
);
|
|
_heroScaleAnim = Tween<double>(begin: 1.0, end: 1.08).animate(
|
|
CurvedAnimation(parent: _heroAnimController, curve: Curves.easeInOut),
|
|
);
|
|
_heroAnimController.repeat(reverse: true);
|
|
_loadData();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_heroAnimController.dispose();
|
|
_heroRotateTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startHeroRotation() {
|
|
_heroRotateTimer?.cancel();
|
|
if (_events.length <= 1) return;
|
|
_heroRotateTimer = Timer.periodic(const Duration(seconds: 6), (_) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_heroImageIndex = (_heroImageIndex + 1) % _events.length.clamp(1, 5);
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_loadingTypes = true;
|
|
});
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_username = prefs.getString('display_name') ??
|
|
prefs.getString('username') ??
|
|
'Guest';
|
|
_pincode = prefs.getString('pincode') ?? 'all';
|
|
|
|
await Future.wait([
|
|
_fetchEventTypes(),
|
|
_fetchEvents(),
|
|
]);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
_loadingTypes = false;
|
|
});
|
|
_startHeroRotation();
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchEventTypes() async {
|
|
try {
|
|
final types = await _eventsService.getEventTypes();
|
|
if (mounted) setState(() => _types = types);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _fetchEvents() async {
|
|
try {
|
|
final events = await _eventsService.getEventsByPincode(_pincode);
|
|
if (mounted) setState(() => _events = events);
|
|
} catch (_) {}
|
|
}
|
|
|
|
List<EventModel> get _filteredEvents {
|
|
if (_selectedTypeLabel != null &&
|
|
_selectedTypeLabel!.toLowerCase() != 'all events') {
|
|
final label = _selectedTypeLabel!.toLowerCase();
|
|
return _events.where((e) {
|
|
final typeName = (e.eventTypeId != null && e.eventTypeId! > 0)
|
|
? (_types
|
|
.firstWhere((t) => t.id == e.eventTypeId,
|
|
orElse: () =>
|
|
EventTypeModel(id: -1, name: '', iconUrl: null))
|
|
.name)
|
|
: '';
|
|
return [e.venueName, e.title, e.name, typeName]
|
|
.any((c) => c != null && c.toLowerCase().contains(label));
|
|
}).toList();
|
|
}
|
|
if (_selectedTypeId != -1) {
|
|
return _events.where((e) => e.eventTypeId == _selectedTypeId).toList();
|
|
}
|
|
return _events;
|
|
}
|
|
|
|
String? _chooseImage(EventModel e) {
|
|
if (e.thumbImg != null && e.thumbImg!.trim().isNotEmpty) {
|
|
return e.thumbImg!.trim();
|
|
}
|
|
if (e.images.isNotEmpty && e.images.first.image.trim().isNotEmpty) {
|
|
return e.images.first.image.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _formatDateBadge(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\n$month';
|
|
}
|
|
} catch (_) {}
|
|
return dateStr;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Container(
|
|
color: const Color(0xFFFAFBFC),
|
|
child: RefreshIndicator(
|
|
onRefresh: _loadData,
|
|
color: theme.colorScheme.primary,
|
|
child: CustomScrollView(
|
|
physics: const BouncingScrollPhysics(
|
|
parent: AlwaysScrollableScrollPhysics(),
|
|
),
|
|
slivers: [
|
|
// Hero section
|
|
SliverToBoxAdapter(child: _buildHeroSection(theme)),
|
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
|
|
|
// Type chips
|
|
if (!_loadingTypes)
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: 48, child: _buildTypeChips(theme)),
|
|
),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 20)),
|
|
|
|
// Section header
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Events Around You',
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: const Color(0xFF111827),
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {},
|
|
child: Text(
|
|
'View All',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: const Color(0xFF2563EB),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
|
|
|
// Event grid
|
|
_buildEventGrid(theme),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 40)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──── Hero Section (website-style: large image + Ken Burns + overlay) ────
|
|
Widget _buildHeroSection(ThemeData theme) {
|
|
final heroEvent = _events.isNotEmpty
|
|
? _events[_heroImageIndex.clamp(0, _events.length - 1)]
|
|
: null;
|
|
final heroImg = heroEvent != null ? _chooseImage(heroEvent) : null;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: SizedBox(
|
|
height: 400,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Background image with Ken Burns
|
|
AnimatedBuilder(
|
|
animation: _heroScaleAnim,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: _heroScaleAnim.value,
|
|
child: child,
|
|
);
|
|
},
|
|
child: heroImg != null
|
|
? AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 800),
|
|
child: CachedNetworkImage(
|
|
key: ValueKey(heroImg),
|
|
imageUrl: heroImg,
|
|
fit: BoxFit.cover,
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
memCacheWidth: 1400,
|
|
memCacheHeight: 800,
|
|
placeholder: (_, __) => Container(
|
|
color: const Color(0xFF0A0E1A),
|
|
),
|
|
errorWidget: (_, __, ___) => Container(
|
|
color: const Color(0xFF0A0E1A),
|
|
),
|
|
),
|
|
)
|
|
: Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFF0F45CF), Color(0xFF082369)],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Dark gradient overlay
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Color(0x4D000000), // 0.3 alpha
|
|
Color(0x99000000), // 0.6 alpha
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Content overlay
|
|
Padding(
|
|
padding: const EdgeInsets.all(40),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (heroEvent != null) ...[
|
|
Text(
|
|
heroEvent.title ?? heroEvent.name ?? 'Discover Amazing Events',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 36,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (heroEvent.place != null || heroEvent.startDate != null)
|
|
Row(
|
|
children: [
|
|
if (heroEvent.startDate != null) ...[
|
|
const Icon(Icons.calendar_today,
|
|
size: 14, color: Colors.white70),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
heroEvent.startDate!,
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
if (heroEvent.startDate != null &&
|
|
heroEvent.place != null)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text('·',
|
|
style: TextStyle(
|
|
color: Colors.white54, fontSize: 14)),
|
|
),
|
|
if (heroEvent.place != null) ...[
|
|
const Icon(Icons.location_on,
|
|
size: 14, color: Colors.white70),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
heroEvent.place!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
] else ...[
|
|
const Text(
|
|
'Discover Amazing\nEvents Near You',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 36,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Find events, book tickets, and explore',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.8),
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if (heroEvent != null) {
|
|
_showFeaturedModal(theme, heroEvent);
|
|
}
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 32, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2563EB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF2563EB)
|
|
.withValues(alpha: 0.4),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Text(
|
|
heroEvent != null ? 'Learn More' : 'Explore Events',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Hero image indicator dots
|
|
if (_events.length > 1)
|
|
Positioned(
|
|
bottom: 16,
|
|
right: 24,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: List.generate(
|
|
_events.length.clamp(0, 5),
|
|
(i) => Container(
|
|
width: i == _heroImageIndex ? 24 : 8,
|
|
height: 8,
|
|
margin: const EdgeInsets.only(left: 6),
|
|
decoration: BoxDecoration(
|
|
color: i == _heroImageIndex
|
|
? Colors.white
|
|
: Colors.white.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──── Featured Event Modal ────
|
|
void _showFeaturedModal(ThemeData theme, EventModel event) {
|
|
final img = _chooseImage(event);
|
|
showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black87,
|
|
builder: (ctx) => Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 700, maxHeight: 500),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: SizedBox(
|
|
width: 700,
|
|
height: 500,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (img != null)
|
|
CachedNetworkImage(
|
|
imageUrl: img,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 1400,
|
|
memCacheHeight: 800,
|
|
)
|
|
else
|
|
Container(color: const Color(0xFF0A0E1A)),
|
|
// Gradient
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.transparent, Color(0xCC000000)],
|
|
),
|
|
),
|
|
),
|
|
// Content
|
|
Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.title ?? event.name ?? '',
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (event.startDate != null)
|
|
Text(
|
|
'${event.startDate}${event.place != null ? ' · ${event.place}' : ''}',
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(ctx).pop();
|
|
widget.onEventTap(event.id, event);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 32, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2563EB),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'Learn More',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// Close button
|
|
Positioned(
|
|
top: 12,
|
|
right: 12,
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.of(ctx).pop(),
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.close,
|
|
color: Colors.white, size: 20),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──── Type Chips (pill style matching website) ────
|
|
Widget _buildTypeChips(ThemeData theme) {
|
|
final chips = <Widget>[];
|
|
|
|
final allSelected =
|
|
_selectedTypeLabel == null && _selectedTypeId == -1;
|
|
chips.add(_chip(theme, 'All Events', allSelected, () {
|
|
setState(() {
|
|
_selectedTypeId = -1;
|
|
_selectedTypeLabel = null;
|
|
});
|
|
}));
|
|
|
|
for (final t in _types) {
|
|
final selected = _selectedTypeLabel == null && _selectedTypeId == t.id;
|
|
chips.add(_chip(theme, t.name, selected, () {
|
|
setState(() {
|
|
_selectedTypeId = t.id;
|
|
_selectedTypeLabel = null;
|
|
});
|
|
}));
|
|
}
|
|
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
scrollDirection: Axis.horizontal,
|
|
itemBuilder: (_, idx) => chips[idx],
|
|
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
|
itemCount: chips.length,
|
|
);
|
|
}
|
|
|
|
Widget _chip(ThemeData theme, String label, bool selected, VoidCallback onTap) {
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
height: 36,
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? const Color(0xFF0F45CF)
|
|
: const Color(0xFFF3F4F6),
|
|
borderRadius: BorderRadius.circular(999),
|
|
boxShadow: selected
|
|
? [
|
|
BoxShadow(
|
|
color: const Color(0xFF0F45CF).withValues(alpha: 0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: selected ? Colors.white : const Color(0xFF111827),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──── Event Grid ────
|
|
Widget _buildEventGrid(ThemeData theme) {
|
|
final list = _filteredEvents;
|
|
|
|
if (_loading) {
|
|
return SliverFillRemaining(
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: theme.colorScheme.primary),
|
|
),
|
|
);
|
|
}
|
|
if (list.isEmpty) {
|
|
return SliverFillRemaining(
|
|
child: Center(
|
|
child: Text('No events available', style: theme.textTheme.bodyMedium),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
sliver: SliverGrid(
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 400,
|
|
mainAxisExtent: 260,
|
|
crossAxisSpacing: 18,
|
|
mainAxisSpacing: 18,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(ctx, idx) {
|
|
final e = list[idx];
|
|
final img = _chooseImage(e);
|
|
return _eventCard(theme, e, img);
|
|
},
|
|
childCount: list.length,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _eventCard(ThemeData theme, EventModel e, String? img) {
|
|
const double cardRadius = 16;
|
|
const double imageHeight = 160;
|
|
|
|
final dateLabel =
|
|
(e.startDate != null && e.endDate != null && e.startDate == e.endDate)
|
|
? e.startDate!
|
|
: ((e.startDate != null && e.endDate != null)
|
|
? '${e.startDate} - ${e.endDate}'
|
|
: (e.startDate ?? ''));
|
|
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () => widget.onEventTap(e.id, e),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(cardRadius),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.06),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Image with date badge
|
|
Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(cardRadius)),
|
|
child: img != null
|
|
? CachedNetworkImage(
|
|
imageUrl: img,
|
|
memCacheWidth: 600,
|
|
memCacheHeight: 320,
|
|
width: double.infinity,
|
|
height: imageHeight,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => Container(
|
|
height: imageHeight,
|
|
color: const Color(0xFFE5E7EB)),
|
|
errorWidget: (_, __, ___) => Container(
|
|
height: imageHeight,
|
|
color: const Color(0xFFE5E7EB)),
|
|
)
|
|
: Container(
|
|
height: imageHeight,
|
|
width: double.infinity,
|
|
color: const Color(0xFFE5E7EB),
|
|
),
|
|
),
|
|
// Date badge
|
|
if (e.startDate != null)
|
|
Positioned(
|
|
top: 10,
|
|
right: 10,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.92),
|
|
borderRadius: BorderRadius.circular(8),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
child: Text(
|
|
_formatDateBadge(e.startDate!),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFF111827),
|
|
height: 1.3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
e.title ?? e.name ?? '',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 15,
|
|
color: Color(0xFF111827),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Row(children: [
|
|
const Icon(Icons.calendar_today,
|
|
size: 13, color: Color(0xFF3B82F6)),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
dateLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: Color(0xFF6B7280)),
|
|
),
|
|
),
|
|
]),
|
|
if (e.place != null) ...[
|
|
const SizedBox(height: 4),
|
|
Row(children: [
|
|
const Icon(Icons.location_on,
|
|
size: 13, color: Color(0xFF22C55E)),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
e.place!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: Color(0xFF6B7280)),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|