Files
Eventify-frontend/lib/screens/home_desktop_screen.dart
Sicherhaven b55f02e057 perf: optimize loading time — paginated API, slim payloads, local category filtering
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>
2026-03-30 10:05:23 +05:30

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)),
),
),
]),
],
],
),
),
),
],
),
),
),
);
}
}