Refactor ActivityCard UI and improve voting functionality

- Updated ActivityCard layout for better visual consistency and responsiveness.
- Simplified the category badge and adjusted styles for better readability.
- Enhanced the voting section with a progress bar and improved button designs.
- Added a new method in Activity model to check if all trip participants approved an activity.
- Improved error handling and validation in ActivityRepository for voting and fetching activities.
- Implemented pagination in ActivityPlacesService for activity searches.
- Removed outdated scripts for cleaning up duplicate images.
This commit is contained in:
Dayron
2025-11-04 20:21:54 +01:00
parent 8ff9e12fd4
commit f6c8432335
19 changed files with 2902 additions and 961 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,29 @@ class ActivityCard extends StatelessWidget {
final VoidCallback? onAddToTrip;
const ActivityCard({
Key? key,
super.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);
final totalVotes = activity.positiveVotes + activity.negativeVotes;
final positivePercentage = totalVotes > 0 ? (activity.positiveVotes / totalVotes) : 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
blurRadius: 10,
offset: const Offset(0, 5),
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
@@ -40,32 +41,32 @@ class ActivityCard extends StatelessWidget {
// Image de l'activité
if (activity.imageUrl != null) ...[
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Stack(
children: [
Image.network(
activity.imageUrl!,
height: 200,
height: 160,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 200,
height: 160,
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
decoration: const BoxDecoration(
color: Color(0xFFF5F5F5),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
child: Icon(
child: const Icon(
Icons.image_not_supported,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
color: Color(0xFF9E9E9E),
),
);
},
),
// Badge catégorie
// Badge catégorie (simplifié)
if (activity.category.isNotEmpty)
Positioned(
top: 12,
@@ -73,13 +74,14 @@ class ActivityCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getCategoryColor(activity.category),
borderRadius: BorderRadius.circular(12),
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: Text(
activity.category,
style: theme.textTheme.bodySmall?.copyWith(
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
@@ -88,6 +90,21 @@ class ActivityCard extends StatelessWidget {
],
),
),
] else ...[
// Placeholder si pas d'image
Container(
height: 160,
width: double.infinity,
decoration: const BoxDecoration(
color: Color(0xFFF5F5F5),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
child: const Icon(
Icons.image_not_supported,
size: 48,
color: Color(0xFF9E9E9E),
),
),
],
// Contenu de la carte
@@ -103,9 +120,10 @@ class ActivityCard extends StatelessWidget {
Expanded(
child: Text(
activity.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -116,19 +134,20 @@ class ActivityCard extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 14, color: Colors.amber),
const Icon(Icons.star, size: 12, color: Color(0xFFFFB400)),
const SizedBox(width: 2),
Text(
activity.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith(
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.amber[800],
color: Color(0xFFB8860B),
),
),
],
@@ -140,161 +159,186 @@ class ActivityCard extends StatelessWidget {
const SizedBox(height: 8),
// Description
// Description (simplifiée)
Text(
activity.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF6B6B6B),
height: 1.4,
),
maxLines: 3,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// Informations supplémentaires
if (activity.priceLevel != null || activity.address != null) ...[
// Adresse (si disponible)
if (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),
const Icon(
Icons.location_on,
size: 14,
color: Color(0xFF9E9E9E),
),
const SizedBox(width: 4),
Expanded(
child: Text(
activity.address!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9E9E9E),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
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),
// Section vote avec barre de progression
if (activity.id.isNotEmpty) ...[
// Barre de progression des votes
Container(
height: 6,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(3),
),
child: totalVotes > 0
? Row(
children: [
if (activity.positiveVotes > 0)
Expanded(
flex: activity.positiveVotes,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF4CAF50),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(3),
bottomLeft: const Radius.circular(3),
topRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero,
bottomRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero,
),
),
),
),
if (activity.negativeVotes > 0)
Expanded(
flex: activity.negativeVotes,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF44336),
borderRadius: BorderRadius.only(
topLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero,
bottomLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero,
topRight: const Radius.circular(3),
bottomRight: const Radius.circular(3),
),
),
),
),
],
)
: null,
),
const SizedBox(height: 8),
// Stats et boutons de vote
Row(
children: [
// Stats des votes
Text(
totalVotes > 0
? '${(positivePercentage * 100).round()}% positif • $totalVotes vote${totalVotes > 1 ? 's' : ''}'
: 'Aucun vote',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9E9E9E),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
const Spacer(),
// Boutons de vote compacts
Row(
children: [
// Vote positif
_buildVoteButton(
_buildCompactVoteButton(
icon: Icons.thumb_up,
count: activity.positiveVotes,
isActive: userVote == 1,
onTap: () => onVote(userVote == 1 ? 0 : 1),
activeColor: Colors.blue,
activeColor: const Color(0xFF4CAF50),
),
Container(
width: 1,
height: 24,
color: theme.colorScheme.onSurface.withOpacity(0.2),
),
// Vote négatif
_buildVoteButton(
const SizedBox(width: 8),
_buildCompactVoteButton(
icon: Icons.thumb_down,
count: activity.negativeVotes,
isActive: userVote == -1,
onTap: () => onVote(userVote == -1 ? 0 : -1),
activeColor: Colors.red,
activeColor: const Color(0xFFF44336),
),
],
),
),
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
],
),
] else ...[
// Pour les activités non sauvegardées
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getScoreColor(activity.totalVotes).withOpacity(0.2),
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: _getScoreColor(activity.totalVotes),
),
Icon(
Icons.info_outline,
size: 14,
color: Color(0xFF9E9E9E),
),
const SizedBox(width: 4),
SizedBox(width: 4),
Text(
'vote${activity.votes.length > 1 ? 's' : ''}',
style: theme.textTheme.bodySmall?.copyWith(
color: _getScoreColor(activity.totalVotes),
'Ajoutez pour voter',
style: TextStyle(
fontSize: 12,
color: Color(0xFF9E9E9E),
),
),
],
),
),
],
),
const Spacer(),
// Bouton d'ajout
if (onAddToTrip != null)
ElevatedButton.icon(
onPressed: onAddToTrip,
icon: const Icon(Icons.add, size: 16),
label: const Text('Ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF007AFF),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
],
),
),
@@ -303,9 +347,8 @@ class ActivityCard extends StatelessWidget {
);
}
Widget _buildVoteButton({
Widget _buildCompactVoteButton({
required IconData icon,
required int count,
required bool isActive,
required VoidCallback onTap,
required Color activeColor,
@@ -313,62 +356,19 @@ class ActivityCard extends StatelessWidget {
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,
),
),
],
],
width: 32,
height: 32,
decoration: BoxDecoration(
color: isActive ? activeColor : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
icon,
size: 16,
color: isActive ? Colors.white : const Color(0xFF9E9E9E),
),
),
);
}
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;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:math' as math;
import '../../blocs/activity/activity_bloc.dart';
import '../../blocs/activity/activity_event.dart';
import '../../models/activity.dart';
@@ -41,10 +42,17 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final keyboardHeight = mediaQuery.viewInsets.bottom;
return Container(
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: mediaQuery.size.height * 0.85,
margin: const EdgeInsets.all(16),
margin: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16,
),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(20),
@@ -84,10 +92,15 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
const Divider(),
// Formulaire
// Formulaire avec SingleChildScrollView pour le scroll automatique
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: math.max(20, keyboardHeight),
),
child: Form(
key: _formKey,
child: Column(