Files
TravelMate/lib/components/activities/activity_card.dart
Dayron 8ff9e12fd4 feat: Implement activity management feature with Firestore integration
- Added AddActivityBottomSheet for adding custom activities to trips.
- Created Activity model to represent tourist activities.
- Developed ActivityRepository for managing activities in Firestore.
- Integrated ActivityPlacesService for searching activities via Google Places API.
- Updated ShowTripDetailsContent to navigate to activities page.
- Enhanced main.dart to include ActivityBloc and necessary repositories.
2025-11-03 16:40:33 +01:00

374 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import '../../models/activity.dart';
/// Widget représentant une carte d'activité avec système de vote
class ActivityCard extends StatelessWidget {
final Activity activity;
final String currentUserId;
final Function(int) onVote;
final VoidCallback? onAddToTrip;
const ActivityCard({
Key? key,
required this.activity,
required this.currentUserId,
required this.onVote,
this.onAddToTrip,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
final userVote = activity.getUserVote(currentUserId);
return Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image de l'activité
if (activity.imageUrl != null) ...[
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: Stack(
children: [
Image.network(
activity.imageUrl!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Icon(
Icons.image_not_supported,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
);
},
),
// Badge catégorie
if (activity.category.isNotEmpty)
Positioned(
top: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getCategoryColor(activity.category),
borderRadius: BorderRadius.circular(12),
),
child: Text(
activity.category,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
],
// Contenu de la carte
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et rating
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
activity.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (activity.rating != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 14, color: Colors.amber),
const SizedBox(width: 2),
Text(
activity.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Colors.amber[800],
),
),
],
),
),
],
],
),
const SizedBox(height: 8),
// Description
Text(
activity.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// Informations supplémentaires
if (activity.priceLevel != null || activity.address != null) ...[
Row(
children: [
if (activity.priceLevel != null) ...[
Icon(
Icons.euro,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
activity.priceLevel!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
if (activity.address != null) ...[
const SizedBox(width: 16),
Icon(
Icons.location_on,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Expanded(
child: Text(
activity.address!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
] else if (activity.address != null) ...[
Icon(
Icons.location_on,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Expanded(
child: Text(
activity.address!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
const SizedBox(height: 12),
],
// Section vote et actions
Row(
children: [
// Boutons de vote
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Vote positif
_buildVoteButton(
icon: Icons.thumb_up,
count: activity.positiveVotes,
isActive: userVote == 1,
onTap: () => onVote(userVote == 1 ? 0 : 1),
activeColor: Colors.blue,
),
Container(
width: 1,
height: 24,
color: theme.colorScheme.onSurface.withOpacity(0.2),
),
// Vote négatif
_buildVoteButton(
icon: Icons.thumb_down,
count: activity.negativeVotes,
isActive: userVote == -1,
onTap: () => onVote(userVote == -1 ? 0 : -1),
activeColor: Colors.red,
),
],
),
),
const Spacer(),
// Bouton d'action
if (onAddToTrip != null)
ElevatedButton(
onPressed: onAddToTrip,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Voter'),
)
else
// Score total
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getScoreColor(activity.totalVotes).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: _getScoreColor(activity.totalVotes),
),
),
const SizedBox(width: 4),
Text(
'vote${activity.votes.length > 1 ? 's' : ''}',
style: theme.textTheme.bodySmall?.copyWith(
color: _getScoreColor(activity.totalVotes),
),
),
],
),
),
],
),
],
),
),
],
),
);
}
Widget _buildVoteButton({
required IconData icon,
required int count,
required bool isActive,
required VoidCallback onTap,
required Color activeColor,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: isActive ? activeColor : Colors.grey,
),
if (count > 0) ...[
const SizedBox(width: 4),
Text(
count.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isActive ? activeColor : Colors.grey,
),
),
],
],
),
),
);
}
Color _getCategoryColor(String category) {
switch (category.toLowerCase()) {
case 'musée':
return Colors.purple;
case 'restaurant':
return Colors.orange;
case 'attraction':
return Colors.blue;
case 'divertissement':
return Colors.pink;
case 'shopping':
return Colors.green;
case 'nature':
return Colors.teal;
case 'culture':
return Colors.indigo;
case 'vie nocturne':
return Colors.deepPurple;
case 'sports':
return Colors.red;
case 'détente':
return Colors.cyan;
default:
return Colors.grey;
}
}
Color _getScoreColor(int score) {
if (score > 0) return Colors.green;
if (score < 0) return Colors.red;
return Colors.grey;
}
}