The mobile calendar layout had a split-height bug where the event list at the bottom was squeezed into whatever pixel crumbs remained after the calendar card and summary bar consumed their fixed space. On small phones or 6-row months (~390px calendar), the events area could shrink to under 100px — barely one card, with no way to scroll. Fix: replace Column + Expanded(ListView) with a CustomScrollView using slivers so the full page — calendar card, summary bar, and event cards — scrolls as one unified surface. SliverFillRemaining handles loading and empty states so they always fill the visible viewport naturally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
736 lines
30 KiB
Dart
736 lines
30 KiB
Dart
// lib/screens/calendar_screen.dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../features/events/services/events_service.dart';
|
|
import '../features/events/models/event_models.dart';
|
|
import 'learn_more_screen.dart';
|
|
import '../core/app_decoration.dart';
|
|
|
|
class CalendarScreen extends StatefulWidget {
|
|
const CalendarScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<CalendarScreen> createState() => _CalendarScreenState();
|
|
}
|
|
|
|
class _CalendarScreenState extends State<CalendarScreen> {
|
|
DateTime visibleMonth = DateTime.now();
|
|
DateTime selectedDate = DateTime.now();
|
|
|
|
final EventsService _service = EventsService();
|
|
|
|
bool _loadingMonth = true;
|
|
bool _loadingDay = false;
|
|
|
|
final Set<String> _markedDates = {};
|
|
final Map<String, int> _dateCounts = {};
|
|
List<EventModel> _eventsOfDay = [];
|
|
|
|
// Scroll controller for the calendar grid
|
|
final ScrollController _calendarGridController = ScrollController();
|
|
|
|
static const List<String> monthNames = [
|
|
'',
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December'
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadMonth(visibleMonth);
|
|
selectedDate = DateTime.now();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _onSelectDate(_ymKey(selectedDate)));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_calendarGridController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadMonth(DateTime dt) async {
|
|
setState(() {
|
|
_loadingMonth = true;
|
|
_markedDates.clear();
|
|
_dateCounts.clear();
|
|
_eventsOfDay = [];
|
|
});
|
|
|
|
final monthName = DateFormat.MMMM().format(dt);
|
|
final year = dt.year;
|
|
|
|
try {
|
|
final res = await _service.getEventsByMonthYear(monthName, year);
|
|
final datesRaw = res['dates'];
|
|
if (datesRaw is List) {
|
|
for (final d in datesRaw) {
|
|
if (d is String) _markedDates.add(d);
|
|
}
|
|
}
|
|
final dateEvents = res['date_events'];
|
|
if (dateEvents is List) {
|
|
for (final item in dateEvents) {
|
|
if (item is Map) {
|
|
final k = item['date_of_event']?.toString();
|
|
final cnt = item['events_of_date'];
|
|
if (k != null) {
|
|
_dateCounts[k] = (cnt is int) ? cnt : int.tryParse(cnt?.toString() ?? '0') ?? 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (e) {
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
|
} finally {
|
|
if (mounted) setState(() => _loadingMonth = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _onSelectDate(String yyyyMMdd) async {
|
|
setState(() {
|
|
_loadingDay = true;
|
|
_eventsOfDay = [];
|
|
final parts = yyyyMMdd.split('-');
|
|
if (parts.length == 3) {
|
|
final y = int.tryParse(parts[0]) ?? DateTime.now().year;
|
|
final m = int.tryParse(parts[1]) ?? DateTime.now().month;
|
|
final d = int.tryParse(parts[2]) ?? DateTime.now().day;
|
|
selectedDate = DateTime(y, m, d);
|
|
}
|
|
});
|
|
|
|
try {
|
|
final events = await _service.getEventsForDate(yyyyMMdd);
|
|
if (mounted) setState(() => _eventsOfDay = events);
|
|
} catch (e) {
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
|
} finally {
|
|
if (mounted) setState(() => _loadingDay = false);
|
|
}
|
|
}
|
|
|
|
void _prevMonth() {
|
|
setState(() => visibleMonth = DateTime(visibleMonth.year, visibleMonth.month - 1, 1));
|
|
_loadMonth(visibleMonth);
|
|
}
|
|
|
|
void _nextMonth() {
|
|
setState(() => visibleMonth = DateTime(visibleMonth.year, visibleMonth.month + 1, 1));
|
|
_loadMonth(visibleMonth);
|
|
}
|
|
|
|
int _daysInMonth(DateTime d) {
|
|
final next = (d.month == 12) ? DateTime(d.year + 1, 1, 1) : DateTime(d.year, d.month + 1, 1);
|
|
return next.subtract(const Duration(days: 1)).day;
|
|
}
|
|
|
|
int _firstWeekdayOfMonth(DateTime d) {
|
|
final first = DateTime(d.year, d.month, 1);
|
|
return first.weekday; // 1=Mon ... 7=Sun
|
|
}
|
|
|
|
String _ymKey(DateTime d) =>
|
|
'${d.year.toString().padLeft(4, "0")}-${d.month.toString().padLeft(2, "0")}-${d.day.toString().padLeft(2, "0")}';
|
|
|
|
// show a premium modal sheet with years 2020..2050 in a 3-column grid, scrollable & draggable
|
|
Future<void> _showYearPicker(BuildContext context) async {
|
|
final startYear = 2020;
|
|
final endYear = 2050;
|
|
final years = List<int>.generate(endYear - startYear + 1, (i) => startYear + i);
|
|
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) {
|
|
final theme = Theme.of(ctx);
|
|
return DraggableScrollableSheet(
|
|
initialChildSize: 0.55,
|
|
minChildSize: 0.32,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: theme.cardColor,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 20, offset: Offset(0, 8))],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// header
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 8.0),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 4),
|
|
Expanded(child: Text('Select Year', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700))),
|
|
IconButton(
|
|
icon: Icon(Icons.close, color: theme.hintColor),
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// grid (scrollable using the passed scrollController)
|
|
Expanded(
|
|
child: GridView.builder(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 3, // 3 columns -> premium style
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 2.4,
|
|
),
|
|
itemCount: years.length,
|
|
itemBuilder: (context, index) {
|
|
final y = years[index];
|
|
final isSelected = visibleMonth.year == y;
|
|
return InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
visibleMonth = DateTime(y, visibleMonth.month, 1);
|
|
});
|
|
_loadMonth(visibleMonth);
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? theme.colorScheme.primary.withOpacity(0.12) : null,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: isSelected ? Border.all(color: theme.colorScheme.primary, width: 1.6) : Border.all(color: Colors.transparent),
|
|
boxShadow: isSelected ? [BoxShadow(color: theme.colorScheme.primary.withOpacity(0.06), blurRadius: 10, offset: Offset(0, 4))] : null,
|
|
),
|
|
child: Text(
|
|
'$y',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
|
color: isSelected ? theme.colorScheme.primary : theme.textTheme.bodyLarge?.color,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMonthYearHeader(BuildContext context, Color primaryColor) {
|
|
final theme = Theme.of(context);
|
|
return Row(
|
|
children: [
|
|
IconButton(icon: Icon(Icons.chevron_left, color: primaryColor), onPressed: _prevMonth, splashRadius: 20),
|
|
Expanded(
|
|
child: Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// plain month label (no dropdown)
|
|
Text(
|
|
monthNames[visibleMonth.month],
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(width: 10),
|
|
// year "dropdown" replaced with premium modal trigger
|
|
InkWell(
|
|
onTap: () => _showYearPicker(context),
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'${visibleMonth.year}',
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Icon(Icons.arrow_drop_down, size: 22, color: theme.textTheme.bodyLarge?.color),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
IconButton(icon: Icon(Icons.chevron_right, color: primaryColor), onPressed: _nextMonth, splashRadius: 20),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Calendar card that shows full rows (including overflow prev/next month dates).
|
|
Widget _calendarCard(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final primaryColor = theme.colorScheme.primary;
|
|
final weekdayShorts = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
|
|
|
// compute rows to guarantee rows * 7 cells
|
|
final daysInMonth = _daysInMonth(visibleMonth);
|
|
final firstWeekday = _firstWeekdayOfMonth(visibleMonth);
|
|
final leadingEmpty = firstWeekday - 1;
|
|
final totalCells = leadingEmpty + daysInMonth;
|
|
final rows = (totalCells / 7).ceil();
|
|
final totalItems = rows * 7;
|
|
|
|
// first date shown (may be prev month's date)
|
|
final firstCellDate = DateTime(visibleMonth.year, visibleMonth.month, 1).subtract(Duration(days: leadingEmpty));
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
|
child: Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
elevation: 8,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min, // tightly wrap grid
|
|
children: [
|
|
// month/year header
|
|
SizedBox(height: 48, child: _buildMonthYearHeader(context, primaryColor)),
|
|
const SizedBox(height: 6),
|
|
// weekday labels row
|
|
SizedBox(
|
|
height: 22,
|
|
child: Row(
|
|
children: List.generate(7, (i) {
|
|
final isWeekend = (i == 5 || i == 6);
|
|
return Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
weekdayShorts[i],
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: isWeekend ? Colors.redAccent.withOpacity(0.9) : (Theme.of(context).brightness == Brightness.dark ? Colors.white70 : Colors.black54),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// GRID: shrinkWrap true ensures all rows are rendered
|
|
GridView.builder(
|
|
shrinkWrap: true,
|
|
controller: _calendarGridController,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
padding: EdgeInsets.zero,
|
|
itemCount: totalItems,
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 7,
|
|
mainAxisSpacing: 4,
|
|
crossAxisSpacing: 4,
|
|
childAspectRatio: 0.78,
|
|
),
|
|
itemBuilder: (context, index) {
|
|
final cellDate = firstCellDate.add(Duration(days: index));
|
|
final inCurrentMonth = cellDate.month == visibleMonth.month && cellDate.year == visibleMonth.year;
|
|
final dayIndex = cellDate.day;
|
|
final key = _ymKey(cellDate);
|
|
final hasEvents = _markedDates.contains(key);
|
|
final eventCount = _dateCounts[key] ?? 0;
|
|
final isSelected = selectedDate.year == cellDate.year && selectedDate.month == cellDate.month && selectedDate.day == cellDate.day;
|
|
final isToday = cellDate.year == DateTime.now().year && cellDate.month == DateTime.now().month && cellDate.day == DateTime.now().day;
|
|
|
|
final dayTextColor = inCurrentMonth
|
|
? (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black87)
|
|
: (Theme.of(context).brightness == Brightness.dark ? Colors.white38 : Colors.grey.shade400);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
if (!inCurrentMonth) {
|
|
setState(() => visibleMonth = DateTime(cellDate.year, cellDate.month, 1));
|
|
_loadMonth(visibleMonth);
|
|
}
|
|
_onSelectDate(key);
|
|
},
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// rounded date cell
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? primaryColor
|
|
: isToday
|
|
? primaryColor.withOpacity(0.12)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'$dayIndex',
|
|
style: TextStyle(
|
|
fontWeight: (isSelected || isToday) ? FontWeight.w700 : FontWeight.w500,
|
|
color: isSelected
|
|
? Colors.white
|
|
: isToday
|
|
? primaryColor
|
|
: dayTextColor,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
// event indicator dots
|
|
if (hasEvents && inCurrentMonth)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(
|
|
eventCount.clamp(1, 3),
|
|
(i) => Container(
|
|
width: 5,
|
|
height: 5,
|
|
margin: EdgeInsets.only(left: i > 0 ? 2 : 0),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? primaryColor
|
|
: const Color(0xFFEF4444),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
const SizedBox(height: 5),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Selected-date summary (now guaranteed outside and below the calendar card)
|
|
Widget _selectedDateSummary(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final shortWeekday = DateFormat('EEEE').format(selectedDate);
|
|
final shortDate = DateFormat('d MMMM, yyyy').format(selectedDate);
|
|
final dayNumber = selectedDate.day;
|
|
final monthLabel = DateFormat('MMM').format(selectedDate).toUpperCase();
|
|
final eventsCount = _eventsOfDay.length;
|
|
final primaryColor = theme.colorScheme.primary;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
|
child: Card(
|
|
elevation: 6,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(color: primaryColor.withOpacity(0.12), borderRadius: BorderRadius.circular(10)),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text('$dayNumber', style: theme.textTheme.headlineSmall?.copyWith(color: primaryColor, fontWeight: FontWeight.bold, fontSize: 18)),
|
|
const SizedBox(height: 2),
|
|
Text(monthLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 11, fontWeight: FontWeight.w700)),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(shortWeekday, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, fontSize: 14)),
|
|
const SizedBox(height: 2),
|
|
Text(shortDate, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 12)),
|
|
]),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text('$eventsCount', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: primaryColor, fontSize: 16)),
|
|
const SizedBox(height: 2),
|
|
Text(eventsCount == 1 ? 'Event' : 'Events', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor, fontSize: 12)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Mobile event card (kept unchanged)
|
|
Widget _eventCardMobile(EventModel e) {
|
|
final theme = Theme.of(context);
|
|
final imgUrl = (e.thumbImg != null && e.thumbImg!.isNotEmpty) ? e.thumbImg! : (e.images.isNotEmpty ? e.images.first.image : null);
|
|
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.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
|
|
child: Card(
|
|
elevation: 6,
|
|
margin: const EdgeInsets.fromLTRB(20, 10, 20, 10),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
|
|
child: imgUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imgUrl,
|
|
height: 150,
|
|
width: double.infinity,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => Container(height: 150, color: theme.dividerColor),
|
|
errorWidget: (_, __, ___) => Container(height: 150, color: theme.dividerColor),
|
|
)
|
|
: Container(height: 150, color: theme.dividerColor, child: Icon(Icons.event, size: 44, color: theme.hintColor)),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(e.title ?? e.name ?? '', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
const SizedBox(height: 10),
|
|
Row(children: [
|
|
Container(width: 26, height: 26, decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(6)), child: Icon(Icons.calendar_today, size: 14, color: theme.colorScheme.primary)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(dateLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
Container(width: 26, height: 26, decoration: BoxDecoration(color: theme.colorScheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(6)), child: Icon(Icons.location_on, size: 14, color: theme.colorScheme.primary)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(e.place ?? '', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
|
|
]),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final width = MediaQuery.of(context).size.width;
|
|
final isMobile = width < 700;
|
|
final theme = Theme.of(context);
|
|
|
|
// For non-mobile, keep original split layout
|
|
if (!isMobile) {
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
body: SafeArea(
|
|
child: Row(
|
|
children: [
|
|
Expanded(flex: 3, child: Padding(padding: const EdgeInsets.all(24.0), child: _calendarCard(context))),
|
|
Expanded(flex: 1, child: _detailsPanel()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// MOBILE layout
|
|
// Stack: extended gradient panel (below appbar) that visually extends behind the calendar.
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
body: Stack(
|
|
children: [
|
|
// Extended blue gradient panel behind calendar (matches reference)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Container(
|
|
height: 260, // controls how much gradient shows behind calendar
|
|
decoration: AppDecoration.blueGradient.copyWith(
|
|
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)),
|
|
boxShadow: [const BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
|
|
),
|
|
// leave child empty — title and bell are placed above
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
|
|
// TOP APP BAR (title centered + notification at top-right) - unchanged placement
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: SizedBox(
|
|
height: 56, // app bar height
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// centered title
|
|
Text(
|
|
"Event's Calendar",
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
|
|
// notification icon at absolute top-right
|
|
Positioned(
|
|
right: 0,
|
|
child: InkWell(
|
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white24,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.notifications_none, color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// CONTENT: whole page scrolls as one — calendar + summary + events
|
|
CustomScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: [
|
|
// Space for app bar + gradient top
|
|
const SliverToBoxAdapter(child: SizedBox(height: 110)),
|
|
|
|
// Calendar card
|
|
SliverToBoxAdapter(child: _calendarCard(context)),
|
|
|
|
// Selected date summary
|
|
SliverToBoxAdapter(child: _selectedDateSummary(context)),
|
|
|
|
// Events area — loading / empty / list
|
|
if (_loadingDay)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: theme.colorScheme.primary),
|
|
),
|
|
)
|
|
else if (_eventsOfDay.isEmpty)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.event_available, size: 48, color: theme.hintColor),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'No events scheduled for this date',
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, idx) => _eventCardMobile(_eventsOfDay[idx]),
|
|
childCount: _eventsOfDay.length,
|
|
),
|
|
),
|
|
|
|
// Bottom padding
|
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _detailsPanel() {
|
|
final theme = Theme.of(context);
|
|
final shortDate = DateFormat('EEE, d MMM').format(selectedDate);
|
|
final eventsCount = _eventsOfDay.length;
|
|
|
|
Widget _buildHeaderCompact() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: AppDecoration.blueGradientRounded(10),
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('${selectedDate.day}', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Text(monthNames[selectedDate.month].substring(0, 3), style: const TextStyle(color: Colors.white70, fontSize: 10))]),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(shortDate, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('$eventsCount ${eventsCount == 1 ? "Event" : "Events"}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor))]),
|
|
const Spacer(),
|
|
IconButton(onPressed: () {}, icon: Icon(Icons.more_horiz, color: Theme.of(context).iconTheme.color)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
_buildHeaderCompact(),
|
|
Divider(height: 1, color: theme.dividerColor),
|
|
Expanded(
|
|
child: _loadingDay
|
|
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
|
|
: _eventsOfDay.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ListView.builder(padding: const EdgeInsets.only(top: 6, bottom: 24), itemCount: _eventsOfDay.length, itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx])),
|
|
)
|
|
]),
|
|
);
|
|
}
|
|
}
|