feat: Redesign calendar page with default week view, improved app bar, and a consolidated activity timeline.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user