1043 lines
39 KiB
Dart
1043 lines
39 KiB
Dart
|
|
import 'dart:async';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
|
|
||
|
|
import '../features/events/models/event_models.dart';
|
||
|
|
import '../features/events/services/events_service.dart';
|
||
|
|
import 'calendar_screen.dart';
|
||
|
|
import 'profile_screen.dart';
|
||
|
|
import 'booking_screen.dart';
|
||
|
|
import 'settings_screen.dart';
|
||
|
|
import 'learn_more_screen.dart';
|
||
|
|
import '../core/app_decoration.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> with SingleTickerProviderStateMixin {
|
||
|
|
final EventsService _eventsService = EventsService();
|
||
|
|
|
||
|
|
// Navigation state
|
||
|
|
int selectedMenu = 0; // 0 = Home, 1 = Calendar, 2 = Profile, 3 = Bookings, 4 = Contribute, 5 = Settings
|
||
|
|
|
||
|
|
// Backend-driven data for Home
|
||
|
|
List<EventModel> _events = [];
|
||
|
|
List<EventTypeModel> _types = [];
|
||
|
|
bool _loading = true;
|
||
|
|
bool _loadingTypes = true;
|
||
|
|
|
||
|
|
// Selection: either a backend id (event type) or a fixed label filter.
|
||
|
|
int _SelectedTypeId = -1;
|
||
|
|
String? _selectedTypeLabel;
|
||
|
|
|
||
|
|
// fixed categories required by product
|
||
|
|
final List<String> _fixedCategories = [
|
||
|
|
'All Events',
|
||
|
|
];
|
||
|
|
|
||
|
|
// User prefs
|
||
|
|
String _username = 'Guest';
|
||
|
|
String _location = 'Unknown';
|
||
|
|
String _pincode = 'all';
|
||
|
|
String? _profileImage;
|
||
|
|
|
||
|
|
// Sidebar text animation
|
||
|
|
late final AnimationController _sidebarTextController;
|
||
|
|
late final Animation<double> _sidebarTextOpacity;
|
||
|
|
|
||
|
|
// Sidebar width constant (used when computing main content width)
|
||
|
|
static const double _sidebarWidth = 220;
|
||
|
|
|
||
|
|
// Topbar compact breakpoint (content width)
|
||
|
|
static const double _compactTopBarWidth = 720;
|
||
|
|
|
||
|
|
// --- marquee (featured events) fields ---
|
||
|
|
late final ScrollController _marqueeController;
|
||
|
|
Timer? _marqueeTimer;
|
||
|
|
// Speed in pixels per second
|
||
|
|
double _marqueeSpeed = 40.0;
|
||
|
|
final Duration _marqueeTick = const Duration(milliseconds: 16);
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
|
||
|
|
_sidebarTextController = AnimationController(vsync: this, duration: const Duration(milliseconds: 420));
|
||
|
|
_sidebarTextOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _sidebarTextController, curve: Curves.easeOut));
|
||
|
|
|
||
|
|
_marqueeController = ScrollController();
|
||
|
|
|
||
|
|
if (widget.skipSidebarEntranceAnimation) {
|
||
|
|
_sidebarTextController.value = 1.0;
|
||
|
|
} else {
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
if (mounted) _sidebarTextController.forward();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// load initial data for home only
|
||
|
|
_loadPreferencesAndData();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_sidebarTextController.dispose();
|
||
|
|
_marqueeTimer?.cancel();
|
||
|
|
_marqueeController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Data loaders ------------------------
|
||
|
|
Future<void> _loadPreferencesAndData() async {
|
||
|
|
setState(() {
|
||
|
|
_loading = true;
|
||
|
|
_loadingTypes = true;
|
||
|
|
});
|
||
|
|
|
||
|
|
final prefs = await SharedPreferences.getInstance();
|
||
|
|
// UI display name should come from display_name (profile). Backend identity is separate.
|
||
|
|
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? 'Jane Doe';
|
||
|
|
_location = prefs.getString('location') ?? 'Whitefield, Bengaluru';
|
||
|
|
_pincode = prefs.getString('pincode') ?? 'all';
|
||
|
|
_profileImage = prefs.getString('profileImage');
|
||
|
|
|
||
|
|
await Future.wait([
|
||
|
|
_fetchEventTypes(),
|
||
|
|
_fetchEventsByPincode(_pincode),
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (mounted) setState(() {
|
||
|
|
_loading = false;
|
||
|
|
_loadingTypes = false;
|
||
|
|
});
|
||
|
|
|
||
|
|
// start marquee when we have events
|
||
|
|
_restartMarquee();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _fetchEventTypes() async {
|
||
|
|
try {
|
||
|
|
final types = await _eventsService.getEventTypes();
|
||
|
|
if (mounted) setState(() => _types = types);
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to load categories: ${e.toString()}')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _fetchEventsByPincode(String pincode) async {
|
||
|
|
try {
|
||
|
|
final events = await _eventsService.getEventsByPincode(pincode);
|
||
|
|
if (mounted) setState(() => _events = events);
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to load events: ${e.toString()}')));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ensure marquee restarts when event list changes
|
||
|
|
_restartMarquee();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Public refresh entry used by UI (pull-to-refresh).
|
||
|
|
Future<void> _refreshHome() async {
|
||
|
|
// Prevent duplicate refresh triggers
|
||
|
|
if (_loading) return;
|
||
|
|
|
||
|
|
setState(() => _loading = true);
|
||
|
|
try {
|
||
|
|
await _loadPreferencesAndData();
|
||
|
|
} finally {
|
||
|
|
// _loadPreferencesAndData normally clears _loading, but ensure we reset if needed.
|
||
|
|
if (mounted && _loading) setState(() => _loading = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Helpers ------------------------
|
||
|
|
String? _chooseEventImage(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;
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _profileAvatar() {
|
||
|
|
// If profile image exists and is a network URL -> show it
|
||
|
|
if (_profileImage != null && _profileImage!.trim().isNotEmpty) {
|
||
|
|
final url = _profileImage!.trim();
|
||
|
|
|
||
|
|
if (url.startsWith('http')) {
|
||
|
|
return CircleAvatar(
|
||
|
|
radius: 20,
|
||
|
|
backgroundColor: Colors.grey.shade200,
|
||
|
|
backgroundImage: NetworkImage(url),
|
||
|
|
onBackgroundImageError: (_, __) {},
|
||
|
|
child: const Icon(Icons.person, color: Colors.transparent),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback → initials (clean & readable)
|
||
|
|
final name = _username.trim();
|
||
|
|
String initials = 'U';
|
||
|
|
|
||
|
|
if (name.isNotEmpty) {
|
||
|
|
if (name.contains('@')) {
|
||
|
|
// Email → first letter only
|
||
|
|
initials = name[0].toUpperCase();
|
||
|
|
} else {
|
||
|
|
final parts = name.split(' ').where((p) => p.isNotEmpty).toList();
|
||
|
|
initials = parts.isEmpty ? 'U' : parts.take(2).map((p) => p[0].toUpperCase()).join();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return CircleAvatar(
|
||
|
|
radius: 20,
|
||
|
|
backgroundColor: Colors.blue.shade600,
|
||
|
|
child: Text(
|
||
|
|
initials,
|
||
|
|
style: const TextStyle(
|
||
|
|
color: Colors.white,
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Marquee control ------------------------
|
||
|
|
void _restartMarquee() {
|
||
|
|
// stop previous timer
|
||
|
|
_marqueeTimer?.cancel();
|
||
|
|
if (_events.isEmpty) {
|
||
|
|
// reset position
|
||
|
|
if (_marqueeController.hasClients) _marqueeController.jumpTo(0);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wait for next frame so scroll metrics are available (after the duplicated row is built)
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
if (!mounted) return;
|
||
|
|
if (!_marqueeController.hasClients) {
|
||
|
|
// Try again next frame
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _restartMarquee());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
final maxScroll = _marqueeController.position.maxScrollExtent;
|
||
|
|
if (maxScroll <= 0) {
|
||
|
|
// Nothing to scroll
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start a periodic timer that increments offset smoothly
|
||
|
|
final tickSeconds = _marqueeTick.inMilliseconds / 1000.0;
|
||
|
|
final delta = _marqueeSpeed * tickSeconds;
|
||
|
|
|
||
|
|
_marqueeTimer?.cancel();
|
||
|
|
_marqueeTimer = Timer.periodic(_marqueeTick, (_) {
|
||
|
|
if (!mounted || !_marqueeController.hasClients) return;
|
||
|
|
|
||
|
|
final cur = _marqueeController.offset;
|
||
|
|
final max = _marqueeController.position.maxScrollExtent;
|
||
|
|
final half = max / 2.0; // because we duplicate the list twice
|
||
|
|
|
||
|
|
double next = cur + delta;
|
||
|
|
if (next >= max) {
|
||
|
|
// wrap back by half (seamless loop)
|
||
|
|
final wrapped = next - half;
|
||
|
|
_marqueeController.jumpTo(wrapped.clamp(0.0, max));
|
||
|
|
} else {
|
||
|
|
// small incremental jump gives smooth appearance
|
||
|
|
_marqueeController.jumpTo(next);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Filtering logic ------------------------
|
||
|
|
List<EventModel> get _filteredEvents {
|
||
|
|
if (_selectedTypeLabel != null && _selectedTypeLabel!.toLowerCase() != 'all events') {
|
||
|
|
final label = _selectedTypeLabel!.toLowerCase();
|
||
|
|
return _events.where((e) {
|
||
|
|
final nameFromBackend = (e.eventTypeId != null && e.eventTypeId! > 0)
|
||
|
|
? (_types.firstWhere((t) => t.id == e.eventTypeId, orElse: () => EventTypeModel(id: -1, name: '', iconUrl: null)).name)
|
||
|
|
: '';
|
||
|
|
final candidateNames = <String?>[
|
||
|
|
e.venueName,
|
||
|
|
e.title,
|
||
|
|
e.name,
|
||
|
|
nameFromBackend,
|
||
|
|
];
|
||
|
|
return candidateNames.any((c) => c != null && c.toLowerCase().contains(label));
|
||
|
|
}).toList();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (_SelectedTypeId != -1) {
|
||
|
|
return _events.where((e) => e.eventTypeId == _SelectedTypeId).toList();
|
||
|
|
}
|
||
|
|
|
||
|
|
return _events;
|
||
|
|
}
|
||
|
|
|
||
|
|
void _selectBackendType(int id) {
|
||
|
|
setState(() {
|
||
|
|
_SelectedTypeId = id;
|
||
|
|
_selectedTypeLabel = null;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
void _selectFixedLabel(String label) {
|
||
|
|
setState(() {
|
||
|
|
if (label.toLowerCase() == 'all events') {
|
||
|
|
_SelectedTypeId = -1;
|
||
|
|
_selectedTypeLabel = null;
|
||
|
|
} else {
|
||
|
|
_SelectedTypeId = -1;
|
||
|
|
_selectedTypeLabel = label;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Left panel (nav) ------------------------
|
||
|
|
Widget _buildLeftPanel() {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
// Layout: top area (logo + nav list) scrolls if necessary, bottom area (host panel + settings) remains pinned.
|
||
|
|
return Container(
|
||
|
|
width: _sidebarWidth,
|
||
|
|
decoration: const BoxDecoration(
|
||
|
|
image: DecorationImage(
|
||
|
|
image: AssetImage('assets/images/gradient_dark_blue.png'),
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: SafeArea(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
FadeTransition(
|
||
|
|
opacity: _sidebarTextOpacity,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||
|
|
child: Text('EVENTIFY', style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, fontSize: 18)),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
|
||
|
|
// Expandable nav list
|
||
|
|
Expanded(
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
physics: const ClampingScrollPhysics(),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
_navItem(icon: Icons.home, label: 'Home', idx: 0),
|
||
|
|
_navItem(icon: Icons.location_on, label: 'Events near you', idx: 0),
|
||
|
|
_navItem(icon: Icons.calendar_today, label: 'Upcoming events', idx: 0),
|
||
|
|
_navItem(icon: Icons.calendar_view_month, label: 'Calendar', idx: 1),
|
||
|
|
|
||
|
|
// <-- Profile between Calendar and Contribute
|
||
|
|
_navItem(icon: Icons.person, label: 'Profile', idx: 2),
|
||
|
|
|
||
|
|
_navItem(icon: Icons.add, label: 'Contribute', idx: 4),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
// optionally more items...
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
// bottom pinned area
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
_buildHostPanel(),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_navItem(icon: Icons.settings, label: 'Settings', idx: 5),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildHostPanel() {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return Container(
|
||
|
|
width: double.infinity,
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
image: const DecorationImage(
|
||
|
|
image: AssetImage('assets/images/gradient_dark_blue.png'),
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
),
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text('Hosting a private or ticketed event?', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold)),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Text('Schedule a call back for setting up event.', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onPrimary.withOpacity(0.85), fontSize: 12)),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
ElevatedButton(onPressed: () {}, style: ElevatedButton.styleFrom(backgroundColor: theme.colorScheme.primary), child: Text('Schedule Call', style: theme.textTheme.labelLarge?.copyWith(color: theme.colorScheme.onPrimary))),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _navItem({required IconData icon, required String label, required int idx}) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
final selected = selectedMenu == idx;
|
||
|
|
final color = selected ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimary.withOpacity(0.9);
|
||
|
|
return ListTile(
|
||
|
|
leading: Icon(icon, color: color),
|
||
|
|
title: Text(label, style: TextStyle(color: color)),
|
||
|
|
dense: true,
|
||
|
|
onTap: () {
|
||
|
|
setState(() {
|
||
|
|
selectedMenu = idx;
|
||
|
|
});
|
||
|
|
if (idx == 0) _refreshHome();
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Top bar (common, responsive) ------------------------
|
||
|
|
Widget _buildTopBar() {
|
||
|
|
// The top bar sits inside the white content area (to the right of the sidebar).
|
||
|
|
// The search box is centered inside the content area (slightly toward the right because of fixed width).
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return LayoutBuilder(builder: (context, constraints) {
|
||
|
|
final isCompact = constraints.maxWidth < _compactTopBarWidth;
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 18),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
// Left: location or icon when compact
|
||
|
|
if (!isCompact)
|
||
|
|
_fullLocationWidget()
|
||
|
|
else
|
||
|
|
IconButton(
|
||
|
|
tooltip: 'Location',
|
||
|
|
onPressed: () {},
|
||
|
|
icon: Icon(Icons.location_on, color: theme.iconTheme.color),
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
|
||
|
|
// Center: place the search bar centered in content area using Center + fixed width box.
|
||
|
|
if (!isCompact)
|
||
|
|
Expanded(
|
||
|
|
child: Align(
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: Padding(
|
||
|
|
// ⬇️ Increase LEFT padding to move search bar more to the right
|
||
|
|
padding: const EdgeInsets.only(left: 120),
|
||
|
|
child: SizedBox(
|
||
|
|
width: 520,
|
||
|
|
height: 44,
|
||
|
|
child: TextField(
|
||
|
|
decoration: InputDecoration(
|
||
|
|
filled: true,
|
||
|
|
fillColor: theme.cardColor,
|
||
|
|
prefixIcon: Icon(Icons.search, color: theme.hintColor),
|
||
|
|
hintText: 'Search events, pages, features...',
|
||
|
|
hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
||
|
|
border: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
borderSide: BorderSide.none,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
style: theme.textTheme.bodyLarge,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else
|
||
|
|
IconButton(
|
||
|
|
tooltip: 'Search',
|
||
|
|
onPressed: () {},
|
||
|
|
icon: Icon(Icons.search, color: theme.iconTheme.color),
|
||
|
|
),
|
||
|
|
|
||
|
|
// Spacer ensures right side stays right-aligned
|
||
|
|
const Spacer(),
|
||
|
|
|
||
|
|
// Right: notifications + (optionally username) + avatar
|
||
|
|
Row(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Stack(
|
||
|
|
children: [
|
||
|
|
IconButton(onPressed: () {}, icon: Icon(Icons.notifications_none, color: theme.iconTheme.color)),
|
||
|
|
Positioned(right: 6, top: 6, child: CircleAvatar(radius: 8, backgroundColor: Colors.red, child: Text('2', style: theme.textTheme.bodySmall?.copyWith(color: Colors.white, fontSize: 10)))),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
if (!isCompact) ...[
|
||
|
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
|
|
Text(_username, style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||
|
|
Text('Explorer', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 12)),
|
||
|
|
]),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
],
|
||
|
|
GestureDetector(onTap: () => _openProfile(), child: _profileAvatar()),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _fullLocationWidget() {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text('Location', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 12)),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Row(children: [
|
||
|
|
Icon(Icons.location_on, size: 18, color: theme.hintColor),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Text(_location, style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Icon(Icons.arrow_drop_down, size: 18, color: theme.hintColor),
|
||
|
|
]),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _openProfile() {
|
||
|
|
setState(() => selectedMenu = 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Home content (index 0) ------------------------
|
||
|
|
Widget _homeContent() {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
// Entire home content is a vertical scrollable wrapped in RefreshIndicator for pull-to-refresh behaviour.
|
||
|
|
return RefreshIndicator(
|
||
|
|
onRefresh: _refreshHome,
|
||
|
|
color: theme.colorScheme.primary,
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
|
||
|
|
// HERO / welcome panel
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.all(20),
|
||
|
|
decoration: AppDecoration.blueGradientRounded(16),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
// left welcome
|
||
|
|
Expanded(
|
||
|
|
flex: 6,
|
||
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
|
|
Text('Welcome Back,', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white.withOpacity(0.9), fontSize: 16)),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Text(_username, style: const TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(width: 16),
|
||
|
|
|
||
|
|
// right: horizontal featured events (backend-driven)
|
||
|
|
Expanded(
|
||
|
|
flex: 4,
|
||
|
|
child: SizedBox(
|
||
|
|
height: 90,
|
||
|
|
child: _events.isEmpty
|
||
|
|
? const SizedBox()
|
||
|
|
: _buildMarqueeFeaturedEvents(),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
|
||
|
|
// Events header
|
||
|
|
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))),
|
||
|
|
TextButton(onPressed: () {}, child: Text('View All', style: theme.textTheme.bodyMedium)),
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
|
||
|
|
// type chips: fixed categories first, then backend categories
|
||
|
|
if (!_loadingTypes) SizedBox(height: 56, child: _buildTypeChips()),
|
||
|
|
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// events area: use LayoutBuilder to decide between grid and horizontal scroll
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
|
|
child: LayoutBuilder(
|
||
|
|
builder: (context, constraints) {
|
||
|
|
// constraints.maxWidth is the available width inside the white content area (after padding)
|
||
|
|
return _buildEventsArea(constraints.maxWidth);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(height: 40),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Build marquee (continuous leftward scroll) by duplicating the events list and using a ScrollController.
|
||
|
|
Widget _buildMarqueeFeaturedEvents() {
|
||
|
|
// Duplicate events for seamless loop
|
||
|
|
final display = <EventModel>[];
|
||
|
|
display.addAll(_events);
|
||
|
|
display.addAll(_events);
|
||
|
|
|
||
|
|
return ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
controller: _marqueeController,
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
physics: const NeverScrollableScrollPhysics(),
|
||
|
|
child: Row(
|
||
|
|
children: List.generate(display.length, (i) {
|
||
|
|
final e = display[i];
|
||
|
|
final img = _chooseEventImage(e);
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.only(right: 12.0),
|
||
|
|
child: SizedBox(width: 220, child: _miniEventCard(e, img)),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildTypeChips() {
|
||
|
|
final chips = <Widget>[];
|
||
|
|
|
||
|
|
// fixed categories first
|
||
|
|
for (final name in _fixedCategories) {
|
||
|
|
final isSelected = _selectedTypeLabel != null ? _selectedTypeLabel == name : (name == 'All Events' && _SelectedTypeId == -1 && _selectedTypeLabel == null);
|
||
|
|
chips.add(_chipWidget(
|
||
|
|
label: name,
|
||
|
|
onTap: () => _selectFixedLabel(name),
|
||
|
|
selected: isSelected,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
// then backend categories (if any)
|
||
|
|
for (final t in _types) {
|
||
|
|
final isSelected = (_selectedTypeLabel == null && _SelectedTypeId == t.id);
|
||
|
|
chips.add(_chipWidget(label: t.name, onTap: () => _selectBackendType(t.id), selected: isSelected));
|
||
|
|
}
|
||
|
|
|
||
|
|
return ListView.separated(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
itemBuilder: (_, idx) => chips[idx],
|
||
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||
|
|
itemCount: chips.length,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _chipWidget({
|
||
|
|
required String label,
|
||
|
|
required VoidCallback onTap,
|
||
|
|
required bool selected,
|
||
|
|
}) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return InkWell(
|
||
|
|
borderRadius: BorderRadius.circular(10),
|
||
|
|
onTap: onTap,
|
||
|
|
child: Container(
|
||
|
|
height: 40, // 👈 fixed height for all chips
|
||
|
|
alignment: Alignment.center,
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: selected ? theme.colorScheme.primary.withOpacity(0.08) : theme.cardColor,
|
||
|
|
borderRadius: BorderRadius.circular(10), // 👈 box shape (not pill)
|
||
|
|
border: Border.all(
|
||
|
|
color: selected ? theme.colorScheme.primary : theme.dividerColor,
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Text(
|
||
|
|
label,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: selected ? theme.colorScheme.primary : theme.textTheme.bodyLarge?.color,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------
|
||
|
|
// EVENTS AREA: fixed 3-columns grid on wide widths; fallback horizontal on narrow widths.
|
||
|
|
// ------------------------
|
||
|
|
Widget _buildEventsArea(double contentWidth) {
|
||
|
|
// Preferred card width you'd like to keep (matches your reference)
|
||
|
|
const double preferredCardWidth = 360.0;
|
||
|
|
const double cardHeight = 220.0; // matches reference proportions
|
||
|
|
const double spacing = 18.0;
|
||
|
|
|
||
|
|
final list = _filteredEvents;
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
if (_loading) {
|
||
|
|
return Padding(padding: const EdgeInsets.only(top: 32), child: Center(child: CircularProgressIndicator(color: theme.colorScheme.primary)));
|
||
|
|
}
|
||
|
|
if (list.isEmpty) {
|
||
|
|
return Padding(padding: const EdgeInsets.symmetric(vertical: 40), child: Center(child: Text('No events available', style: theme.textTheme.bodyMedium)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determine if we have space to show exactly 3 columns.
|
||
|
|
// Required width for 3 columns = 3 * preferredCardWidth + 2 * spacing
|
||
|
|
final double requiredWidthForThree = preferredCardWidth * 3 + spacing * 2;
|
||
|
|
|
||
|
|
if (contentWidth >= requiredWidthForThree) {
|
||
|
|
// Enough space: force a 3-column grid (this ensures exactly 3 cards per row)
|
||
|
|
return GridView.builder(
|
||
|
|
shrinkWrap: true,
|
||
|
|
physics: const NeverScrollableScrollPhysics(),
|
||
|
|
itemCount: list.length,
|
||
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
|
|
crossAxisCount: 3, // always 3 when space allows
|
||
|
|
mainAxisExtent: cardHeight,
|
||
|
|
crossAxisSpacing: spacing,
|
||
|
|
mainAxisSpacing: spacing,
|
||
|
|
),
|
||
|
|
itemBuilder: (ctx, idx) {
|
||
|
|
final e = list[idx];
|
||
|
|
final img = _chooseEventImage(e);
|
||
|
|
return _eventCardForGrid(e, img);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// Not enough space for 3 columns — use horizontal scroll preserving card width.
|
||
|
|
// This keeps behaviour sensible on narrower desktop windows.
|
||
|
|
final double preferredWidth = preferredCardWidth;
|
||
|
|
return SizedBox(
|
||
|
|
height: cardHeight,
|
||
|
|
child: ListView.separated(
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
itemCount: list.length,
|
||
|
|
separatorBuilder: (_, __) => const SizedBox(width: spacing),
|
||
|
|
itemBuilder: (ctx, idx) {
|
||
|
|
final e = list[idx];
|
||
|
|
final img = _chooseEventImage(e);
|
||
|
|
return SizedBox(width: preferredWidth, child: _eventCardForFixedSize(e, img, preferredWidth));
|
||
|
|
},
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// small horizontal card used inside HERO right area
|
||
|
|
Widget _miniEventCard(EventModel e, String? img) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return Container(
|
||
|
|
width: 220,
|
||
|
|
padding: const EdgeInsets.all(8),
|
||
|
|
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(12)),
|
||
|
|
child: Row(children: [
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
child: img != null ? Image.network(img, width: 64, height: 64, fit: BoxFit.cover) : Container(width: 64, height: 64, color: Theme.of(context).dividerColor),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
|
|
Text(e.title ?? e.name ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Text(e.startDate ?? '', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor, fontSize: 12)),
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- Cards ----------
|
||
|
|
// Card used in grid (we can rely on grid cell having fixed height)
|
||
|
|
Widget _eventCardForGrid(EventModel e, String? img) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
// Styling constants to match reference
|
||
|
|
const double cardRadius = 16.0;
|
||
|
|
const double imageHeight = 140.0;
|
||
|
|
const double horizontalPadding = 12.0;
|
||
|
|
const double verticalPadding = 10.0;
|
||
|
|
|
||
|
|
// Friendly formatted date label
|
||
|
|
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 GestureDetector(
|
||
|
|
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
|
||
|
|
child: Container(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: theme.cardColor,
|
||
|
|
borderRadius: BorderRadius.circular(cardRadius),
|
||
|
|
boxShadow: [
|
||
|
|
BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 18, offset: const Offset(0, 10)),
|
||
|
|
BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 6, offset: const Offset(0, 3)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// image flush to top corners
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
||
|
|
child: img != null
|
||
|
|
? Image.network(img, width: double.infinity, height: imageHeight, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor))
|
||
|
|
: Container(height: imageHeight, width: double.infinity, color: theme.dividerColor),
|
||
|
|
),
|
||
|
|
|
||
|
|
// content area
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// Title (2 lines)
|
||
|
|
Text(
|
||
|
|
e.title ?? e.name ?? '',
|
||
|
|
maxLines: 2,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 14),
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
|
||
|
|
// single row: date • location (icons small, subtle)
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
// calendar small badge
|
||
|
|
Container(
|
||
|
|
width: 18,
|
||
|
|
height: 18,
|
||
|
|
decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(4)),
|
||
|
|
child: Icon(Icons.calendar_today, size: 12, color: theme.colorScheme.primary),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
dateLabel,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 13),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
const Padding(
|
||
|
|
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||
|
|
child: Text('•', style: TextStyle(color: Colors.black26)),
|
||
|
|
),
|
||
|
|
|
||
|
|
Container(
|
||
|
|
width: 18,
|
||
|
|
height: 18,
|
||
|
|
decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(4)),
|
||
|
|
child: Icon(Icons.location_on, size: 12, color: theme.colorScheme.primary),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
e.place ?? '',
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 13),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Card used in horizontal list (fixed width)
|
||
|
|
Widget _eventCardForFixedSize(EventModel e, String? img, double width) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
// Styling constants to match grid card
|
||
|
|
const double cardRadius = 16.0;
|
||
|
|
const double imageHeight = 140.0;
|
||
|
|
const double horizontalPadding = 12.0;
|
||
|
|
const double verticalPadding = 10.0;
|
||
|
|
|
||
|
|
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 GestureDetector(
|
||
|
|
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
|
||
|
|
child: Container(
|
||
|
|
width: width,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: theme.cardColor,
|
||
|
|
borderRadius: BorderRadius.circular(cardRadius),
|
||
|
|
boxShadow: [
|
||
|
|
BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 18, offset: const Offset(0, 10)),
|
||
|
|
BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 6, offset: const Offset(0, 3)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
||
|
|
child: img != null
|
||
|
|
? Image.network(img, width: width, height: imageHeight, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor))
|
||
|
|
: Container(height: imageHeight, width: width, color: theme.dividerColor),
|
||
|
|
),
|
||
|
|
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
e.title ?? e.name ?? '',
|
||
|
|
maxLines: 2,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 14),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 18,
|
||
|
|
height: 18,
|
||
|
|
decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(4)),
|
||
|
|
child: Icon(Icons.calendar_today, size: 12, color: theme.colorScheme.primary),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Flexible(child: Text(dateLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 13))),
|
||
|
|
const Padding(
|
||
|
|
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||
|
|
child: Text('•', style: TextStyle(color: Colors.black26)),
|
||
|
|
),
|
||
|
|
Container(
|
||
|
|
width: 18,
|
||
|
|
height: 18,
|
||
|
|
decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(4)),
|
||
|
|
child: Icon(Icons.location_on, size: 12, color: theme.colorScheme.primary),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(child: Text(e.place ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 13))),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Page routing ------------------------
|
||
|
|
Widget _getCurrentPage() {
|
||
|
|
switch (selectedMenu) {
|
||
|
|
case 1:
|
||
|
|
return const CalendarScreen();
|
||
|
|
case 2:
|
||
|
|
return const ProfileScreen();
|
||
|
|
case 3:
|
||
|
|
return BookingScreen(onBook: () {}, image: '');
|
||
|
|
case 4:
|
||
|
|
// Contribute placeholder (kept simple)
|
||
|
|
return SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(28),
|
||
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
|
|
Text('Contribute', style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
const Text('Submit events or contact the Eventify team.'),
|
||
|
|
const SizedBox(height: 24),
|
||
|
|
Card(
|
||
|
|
elevation: 1,
|
||
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.all(18),
|
||
|
|
child: Column(children: [
|
||
|
|
TextField(decoration: InputDecoration(labelText: 'Event title', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(decoration: InputDecoration(labelText: 'Location', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(maxLines: 4, decoration: InputDecoration(labelText: 'Description', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
Row(children: [
|
||
|
|
ElevatedButton(onPressed: () {}, child: const Text('Submit')),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
OutlinedButton(onPressed: () {}, child: const Text('Reset')),
|
||
|
|
])
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
case 5:
|
||
|
|
return const SettingsScreen();
|
||
|
|
default:
|
||
|
|
return _homeContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------ Build ------------------------
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||
|
|
body: Row(
|
||
|
|
children: [
|
||
|
|
_buildLeftPanel(),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
// Show top bar ONLY when Home is active
|
||
|
|
if (selectedMenu == 0) _buildTopBar(),
|
||
|
|
// Page content under the top bar (or directly if top bar hidden)
|
||
|
|
Expanded(child: _getCurrentPage()),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|