feat : post respect ratio and height

This commit is contained in:
Yaël Perret
2025-08-26 22:32:11 +02:00
parent 174f86e581
commit 1332fddeeb
3 changed files with 634 additions and 76 deletions

View File

@@ -0,0 +1,478 @@
import 'package:flutter/material.dart';
class PostCardWidget extends StatelessWidget {
final String title;
final String content;
final String authorName;
final String authorImageUrl;
final DateTime publishDate;
final List<String>? imageUrls;
final int likesCount;
final int commentsCount;
final int sharesCount;
final VoidCallback? onLike;
final VoidCallback? onComment;
final VoidCallback? onShare;
const PostCardWidget({
Key? key,
required this.title,
required this.content,
required this.authorName,
required this.authorImageUrl,
required this.publishDate,
this.imageUrls,
this.likesCount = 0,
this.commentsCount = 0,
this.sharesCount = 0,
this.onLike,
this.onComment,
this.onShare,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16.0),
//elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec auteur et date
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
// Photo de profil de l'auteur
CircleAvatar(
radius: 20,
backgroundImage: NetworkImage(authorImageUrl),
onBackgroundImageError: (_, __) {},
child: authorImageUrl.isEmpty
? const Icon(Icons.person, size: 20)
: null,
),
const SizedBox(width: 12),
// Nom et date
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
_formatDate(publishDate),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
],
),
),
// Menu options
IconButton(
icon: const Icon(Icons.more_vert, color: Colors.grey),
onPressed: () {
// Action pour le menu
},
),
],
),
),
// Contenu du post
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Contenu
Text(
content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
height: 1.4,
),
),
],
),
),
// Images avec boutons d'action si présentes
if (imageUrls != null && imageUrls!.isNotEmpty)
Container(
//margin: const EdgeInsets.symmetric(vertical: 12.0),
height: 200,
child: Stack(
children: [
// Images
PageView.builder(
itemCount: imageUrls!.length,
itemBuilder: (context, index) {
return Container(
//margin: const EdgeInsets.symmetric(horizontal: 16.0),
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(0),
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(0),
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: _buildImage(imageUrls![index]),
),
);
},
),
// Boutons d'action en overlay à droite
Positioned(
right: 24,
top: 0,
bottom: 0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VerticalActionButton(
icon: Icons.favorite_outline,
count: likesCount,
onPressed: onLike,
),
const SizedBox(height: 16),
_VerticalActionButton(
icon: Icons.comment_outlined,
count: commentsCount,
onPressed: onComment,
),
const SizedBox(height: 16),
_VerticalActionButton(
icon: Icons.share_outlined,
count: sharesCount,
onPressed: onShare,
),
],
),
),
],
),
),
// Si pas d'image, boutons d'action en bas à droite
if (imageUrls == null || imageUrls!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HorizontalActionButton(
icon: Icons.favorite_outline,
count: likesCount,
onPressed: onLike,
),
const SizedBox(width: 16),
_HorizontalActionButton(
icon: Icons.comment_outlined,
count: commentsCount,
onPressed: onComment,
),
const SizedBox(width: 16),
_HorizontalActionButton(
icon: Icons.share_outlined,
count: sharesCount,
onPressed: onShare,
),
],
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays > 7) {
return '${date.day}/${date.month}/${date.year}';
} else if (difference.inDays > 0) {
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inHours > 0) {
return '${difference.inHours}h';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}min';
} else {
return 'À l\'instant';
}
}
Widget _buildImage(String imageUrl) {
// Vérifie si c'est une image d'assets ou une URL réseau
if (imageUrl.startsWith('assets/')) {
return Image.asset(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
print('Erreur de chargement d\'asset: $error');
return _buildErrorWidget();
},
);
} else {
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey[300],
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
print('Erreur de chargement d\'image réseau: $error');
return _buildErrorWidget();
},
);
}
}
Widget _buildErrorWidget() {
return Container(
color: Colors.grey[300],
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image,
size: 50,
color: Colors.grey[600],
),
const SizedBox(height: 8),
Text(
'Image non disponible',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
);
}
}
class _VerticalActionButton extends StatefulWidget {
final IconData icon;
final int? count;
final VoidCallback? onPressed;
const _VerticalActionButton({
required this.icon,
this.count,
this.onPressed,
});
@override
State<_VerticalActionButton> createState() => _VerticalActionButtonState();
}
class _VerticalActionButtonState extends State<_VerticalActionButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.85,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _handleTap() async {
await _animationController.forward();
await _animationController.reverse();
widget.onPressed?.call();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(20),
),
child: Icon(
widget.icon,
size: 24,
color: Colors.white,
),
),
if (widget.count != null && widget.count! > 0) ...[
const SizedBox(height: 4),
Text(
widget.count! > 999 ? '999+' : '${widget.count}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
);
},
),
);
}
}
class _HorizontalActionButton extends StatefulWidget {
final IconData icon;
final int? count;
final VoidCallback? onPressed;
const _HorizontalActionButton({
required this.icon,
this.count,
this.onPressed,
});
@override
State<_HorizontalActionButton> createState() => _HorizontalActionButtonState();
}
class _HorizontalActionButtonState extends State<_HorizontalActionButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.85,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _handleTap() async {
await _animationController.forward();
await _animationController.reverse();
widget.onPressed?.call();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(20),
),
child: Icon(
widget.icon,
size: 20,
color: Colors.grey[900],
),
),
if (widget.count != null && widget.count! > 0) ...[
const SizedBox(height: 4),
Text(
widget.count! > 999 ? '999+' : '${widget.count}',
style: TextStyle(
color: Colors.grey[100],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'dart:async';
class PostCardWidget extends StatelessWidget {
class PostCardWidget extends StatefulWidget {
final String title;
final String content;
final String authorName;
@@ -13,6 +14,7 @@ class PostCardWidget extends StatelessWidget {
final VoidCallback? onLike;
final VoidCallback? onComment;
final VoidCallback? onShare;
final double? aspectRatio; // Nouveau paramètre pour le ratio (largeur/hauteur)
const PostCardWidget({
Key? key,
@@ -28,8 +30,15 @@ class PostCardWidget extends StatelessWidget {
this.onLike,
this.onComment,
this.onShare,
this.aspectRatio, // null = ratio naturel de l'image, ex: 16/9, 4/3, 1/1
}) : super(key: key);
@override
State<PostCardWidget> createState() => _PostCardWidgetState();
}
class _PostCardWidgetState extends State<PostCardWidget> {
@override
Widget build(BuildContext context) {
return Card(
@@ -49,9 +58,9 @@ class PostCardWidget extends StatelessWidget {
// Photo de profil de l'auteur
CircleAvatar(
radius: 20,
backgroundImage: NetworkImage(authorImageUrl),
backgroundImage: NetworkImage(widget.authorImageUrl),
onBackgroundImageError: (_, __) {},
child: authorImageUrl.isEmpty
child: widget.authorImageUrl.isEmpty
? const Icon(Icons.person, size: 20)
: null,
),
@@ -62,7 +71,7 @@ class PostCardWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorName,
widget.authorName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
@@ -70,7 +79,7 @@ class PostCardWidget extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
_formatDate(publishDate),
_formatDate(widget.publishDate),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
@@ -97,7 +106,7 @@ class PostCardWidget extends StatelessWidget {
children: [
// Contenu
Text(
content,
widget.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
height: 1.4,
@@ -108,15 +117,23 @@ class PostCardWidget extends StatelessWidget {
),
// Images avec boutons d'action si présentes
if (imageUrls != null && imageUrls!.isNotEmpty)
Container(
//margin: const EdgeInsets.symmetric(vertical: 12.0),
height: 200,
if (widget.imageUrls != null && widget.imageUrls!.isNotEmpty)
FutureBuilder<double>(
future: getImageHeightWithRatio(
widget.imageUrls![0], // Utilise la première image pour calculer la hauteur
MediaQuery.of(context).size.width - 32, // Largeur du conteneur (avec padding)
widget.aspectRatio, // Ratio forcé ou null pour le ratio naturel
),
builder: (context, snapshot) {
final imageHeight = snapshot.data ?? 200.0; // Hauteur par défaut si pas encore calculée
return Container(
height: imageHeight,
child: Stack(
children: [
// Images
PageView.builder(
itemCount: imageUrls!.length,
itemCount: widget.imageUrls!.length,
itemBuilder: (context, index) {
return Container(
//margin: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -135,7 +152,7 @@ class PostCardWidget extends StatelessWidget {
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: _buildImage(imageUrls![index]),
child: _buildImage(widget.imageUrls![index]),
),
);
},
@@ -150,30 +167,32 @@ class PostCardWidget extends StatelessWidget {
children: [
_VerticalActionButton(
icon: Icons.favorite_outline,
count: likesCount,
onPressed: onLike,
count: widget.likesCount,
onPressed: widget.onLike,
),
const SizedBox(height: 16),
_VerticalActionButton(
icon: Icons.comment_outlined,
count: commentsCount,
onPressed: onComment,
count: widget.commentsCount,
onPressed: widget.onComment,
),
const SizedBox(height: 16),
_VerticalActionButton(
icon: Icons.share_outlined,
count: sharesCount,
onPressed: onShare,
count: widget.sharesCount,
onPressed: widget.onShare,
),
],
),
),
],
),
);
},
),
// Si pas d'image, boutons d'action en bas à droite
if (imageUrls == null || imageUrls!.isEmpty)
if (widget.imageUrls == null || widget.imageUrls!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0),
child: Row(
@@ -182,20 +201,20 @@ class PostCardWidget extends StatelessWidget {
children: [
_HorizontalActionButton(
icon: Icons.favorite_outline,
count: likesCount,
onPressed: onLike,
count: widget.likesCount,
onPressed: widget.onLike,
),
const SizedBox(width: 16),
_HorizontalActionButton(
icon: Icons.comment_outlined,
count: commentsCount,
onPressed: onComment,
count: widget.commentsCount,
onPressed: widget.onComment,
),
const SizedBox(width: 16),
_HorizontalActionButton(
icon: Icons.share_outlined,
count: sharesCount,
onPressed: onShare,
count: widget.sharesCount,
onPressed: widget.onShare,
),
],
),
@@ -223,6 +242,66 @@ class PostCardWidget extends StatelessWidget {
}
}
/// Récupère les dimensions de l'image (largeur et hauteur)
Future<Size?> getImageDimensions(String imageUrl) async {
try {
ImageProvider imageProvider;
if (imageUrl.startsWith('assets/')) {
imageProvider = AssetImage(imageUrl);
} else {
imageProvider = NetworkImage(imageUrl);
}
final ImageStream stream = imageProvider.resolve(const ImageConfiguration());
final Completer<Size?> completer = Completer();
late ImageStreamListener listener;
listener = ImageStreamListener((ImageInfo info, bool synchronousCall) {
final double width = info.image.width.toDouble();
final double height = info.image.height.toDouble();
stream.removeListener(listener);
completer.complete(Size(width, height));
}, onError: (dynamic exception, StackTrace? stackTrace) {
stream.removeListener(listener);
completer.complete(null);
});
stream.addListener(listener);
return completer.future;
} catch (e) {
print('Erreur lors de la récupération des dimensions de l\'image: $e');
return null;
}
}
/// Récupère uniquement la hauteur de l'image
Future<double?> getImageHeight(String imageUrl) async {
final dimensions = await getImageDimensions(imageUrl);
return dimensions?.height;
}
/// Calcule la hauteur de l'image en fonction de la largeur du conteneur
Future<double?> getImageHeightForWidth(String imageUrl, double containerWidth) async {
final dimensions = await getImageDimensions(imageUrl);
if (dimensions == null) return null;
final aspectRatio = dimensions.width / dimensions.height;
return containerWidth / aspectRatio;
}
/// Calcule la hauteur en respectant un ratio spécifique ou le ratio naturel
Future<double> getImageHeightWithRatio(String imageUrl, double containerWidth, double? forcedRatio) async {
if (forcedRatio != null) {
// Utilise le ratio forcé (largeur/hauteur)
return containerWidth / forcedRatio;
} else {
// Utilise le ratio naturel de l'image
final naturalHeight = await getImageHeightForWidth(imageUrl, containerWidth);
return naturalHeight ?? 200.0; // Fallback si l'image ne peut pas être chargée
}
}
Widget _buildImage(String imageUrl) {
// Vérifie si c'est une image d'assets ou une URL réseau
if (imageUrl.startsWith('assets/')) {
@@ -354,7 +433,7 @@ class _VerticalActionButtonState extends State<_VerticalActionButton>
),
child: Icon(
widget.icon,
size: 24,
size: 30,
color: Colors.white,
),
),
@@ -365,7 +444,7 @@ class _VerticalActionButtonState extends State<_VerticalActionButton>
widget.count! > 999 ? '999+' : '${widget.count}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),

View File

@@ -214,6 +214,7 @@ class EventDetailsView extends StackedView<EventDetailsViewModel> {
imageUrls: index % 3 == 0 ? ['assets/images/Affiche.jpg'] : null,
likesCount: (index + 1) * 5,
commentsCount: (index + 1) * 2,
aspectRatio: 4/5,
onLike: () {
// Action lors du clic sur "J'aime"
print('Like publication ${index + 1}');