feat: Redesign calendar page with default week view, improved app bar, and a consolidated activity timeline.

This commit is contained in:
Van Leemput Dayron
2025-12-03 23:51:16 +01:00
parent a74d76b485
commit cf4c6447dd
4 changed files with 699 additions and 328 deletions

View File

@@ -20,7 +20,7 @@ class CalendarPage extends StatefulWidget {
class _CalendarPageState extends State<CalendarPage> {
late DateTime _focusedDay;
DateTime? _selectedDay;
CalendarFormat _calendarFormat = CalendarFormat.month;
CalendarFormat _calendarFormat = CalendarFormat.week;
@override
void initState() {
@@ -70,13 +70,32 @@ class _CalendarPageState extends State<CalendarPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode
? theme.scaffoldBackgroundColor
: Colors.white,
appBar: AppBar(
title: const Text('Calendrier du voyage'),
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
title: Text(
widget.trip.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios, color: theme.colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(Icons.people, color: theme.colorScheme.onSurface),
onPressed: () {}, // TODO: Show participants
),
],
),
body: BlocBuilder<ActivityBloc, ActivityState>(
builder: (context, state) {
@@ -88,8 +107,7 @@ class _CalendarPageState extends State<CalendarPage> {
if (state is ActivityLoaded) {
allActivities = state.activities;
} else if (state is ActivitySearchResults) {
// Fallback if we are in search state, though ideally we should be in loaded state
// This might happen if we navigate back and forth
// Fallback if we are in search state
}
// Filter approved activities
@@ -113,215 +131,124 @@ class _CalendarPageState extends State<CalendarPage> {
scheduledActivities,
);
// Sort by time
selectedActivities.sort((a, b) => a.date!.compareTo(b.date!));
return Column(
children: [
TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) {
return isSameDay(_selectedDay, day);
},
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
// Calendar Strip
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isDarkMode ? theme.cardColor : Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: theme.textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
leftChevronIcon: const Icon(Icons.chevron_left),
rightChevronIcon: const Icon(Icons.chevron_right),
),
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
selectedDecoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
markerDecoration: const BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
),
),
selectedDayPredicate: (day) {
return isSameDay(_selectedDay, day);
},
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
eventLoader: (day) {
return _getActivitiesForDay(day, scheduledActivities);
},
calendarBuilders: CalendarBuilders(
markerBuilder: (context, day, events) {
if (events.isEmpty) return null;
return Positioned(
bottom: 1,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
),
),
);
},
eventLoader: (day) {
return _getActivitiesForDay(day, scheduledActivities);
},
),
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
selectedDecoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
),
const Divider(),
const SizedBox(height: 16),
// Timeline View
Expanded(
child: Row(
children: [
// Scheduled Activities for Selected Day
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline
if (selectedActivities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'Activités du ${DateFormat('dd/MM/yyyy').format(_selectedDay!)}',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: selectedActivities.isEmpty
? Center(
child: Text(
'Aucune activité prévue',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),
),
),
)
: ListView.builder(
itemCount: selectedActivities.length,
itemBuilder: (context, index) {
final activity =
selectedActivities[index];
return ListTile(
title: Text(activity.name),
subtitle: Text(
'${activity.category} - ${DateFormat('HH:mm').format(activity.date!)}',
),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
context.read<ActivityBloc>().add(
UpdateActivityDate(
tripId: widget.trip.id!,
activityId: activity.id,
date: null,
),
);
},
),
);
},
),
),
],
),
),
const VerticalDivider(),
// Unscheduled Activities
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'À planifier',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: unscheduledActivities.isEmpty
? Center(
child: Text(
'Tout est planifié !',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: unscheduledActivities.length,
itemBuilder: (context, index) {
final activity =
unscheduledActivities[index];
return Draggable<Activity>(
data: activity,
feedback: Material(
elevation: 4,
child: Container(
padding: const EdgeInsets.all(8),
color: theme.cardColor,
child: Text(activity.name),
),
),
child: ListTile(
title: Text(
activity.name,
style: theme.textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (_selectedDay != null) {
_selectTimeAndSchedule(
activity,
_selectedDay!,
);
}
},
),
),
);
},
),
),
// Zone de drop pour le calendrier
DragTarget<Activity>(
onWillAcceptWithDetails: (details) => true,
onAcceptWithDetails: (details) {
if (_selectedDay != null) {
_selectTimeAndSchedule(
details.data,
_selectedDay!,
);
}
},
builder: (context, candidateData, rejectedData) {
return Container(
height: 50,
color: candidateData.isNotEmpty
? theme.colorScheme.primary.withValues(
alpha: 0.1,
)
: null,
child: Center(
child: Text(
'Glisser ici pour planifier',
style: TextStyle(
color: theme.colorScheme.primary,
),
),
'Aucune activité prévue ce jour',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.5,
),
);
},
),
),
),
],
)
else
...selectedActivities.map((activity) {
return _buildTimelineItem(activity, theme);
}),
const SizedBox(height: 32),
// Unscheduled Activities Section
Text(
'Activités à ajouter',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
const SizedBox(height: 16),
if (unscheduledActivities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Toutes les activités sont planifiées !',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.5,
),
),
),
)
else
...unscheduledActivities.map((activity) {
return _buildUnscheduledActivityCard(activity, theme);
}),
const SizedBox(height: 32),
],
),
),
),
],
@@ -330,4 +257,160 @@ class _CalendarPageState extends State<CalendarPage> {
),
);
}
Widget _buildTimelineItem(Activity activity, ThemeData theme) {
final timeFormat = DateFormat('HH:mm'); // 10:00
final endTimeFormat = DateFormat('HH:mm'); // 11:30 (simulated duration)
// Simulate duration (1h30)
final endTime = activity.date!.add(const Duration(hours: 1, minutes: 30));
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Time Column
SizedBox(
width: 50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${activity.date!.hour}h',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
// Timeline Line
// Expanded(child: Container()), // Placeholder for line if needed
// Activity Card
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getCategoryColor(
activity.category,
).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border(
left: BorderSide(
color: _getCategoryColor(activity.category),
width: 4,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'${timeFormat.format(activity.date!)} - ${endTimeFormat.format(endTime)}',
style: theme.textTheme.bodyMedium?.copyWith(
color: _getCategoryColor(activity.category),
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
);
}
Widget _buildUnscheduledActivityCard(Activity activity, ThemeData theme) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
leading: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _getCategoryColor(activity.category).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
_getCategoryIcon(activity.category),
color: _getCategoryColor(activity.category),
),
),
title: Text(
activity.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
activity.category,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
trailing: IconButton(
icon: const Icon(Icons.grid_view), // Drag handle icon
onPressed: () {
if (_selectedDay != null) {
_selectTimeAndSchedule(activity, _selectedDay!);
}
},
),
),
);
}
Color _getCategoryColor(String category) {
// Simple mapping based on category name
// You might want to use the enum if possible, but category is String in Activity model
if (category.toLowerCase().contains('musée') ||
category.toLowerCase().contains('museum'))
return Colors.blue;
if (category.toLowerCase().contains('restaurant') ||
category.toLowerCase().contains('food'))
return Colors.orange;
if (category.toLowerCase().contains('nature') ||
category.toLowerCase().contains('park'))
return Colors.green;
if (category.toLowerCase().contains('photo') ||
category.toLowerCase().contains('attraction'))
return Colors.purple;
if (category.toLowerCase().contains('détente') ||
category.toLowerCase().contains('relax'))
return Colors.pink;
return Colors.teal;
}
IconData _getCategoryIcon(String category) {
if (category.toLowerCase().contains('musée')) return Icons.museum;
if (category.toLowerCase().contains('restaurant')) return Icons.restaurant;
if (category.toLowerCase().contains('nature')) return Icons.nature;
if (category.toLowerCase().contains('photo')) return Icons.camera_alt;
if (category.toLowerCase().contains('détente'))
return Icons.icecream; // Gelato icon :)
return Icons.place;
}
}