fix(chat): Correction race condition + Implémentation TODOs

## Corrections Critiques

### Race Condition - Statuts de Messages
- Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas
- Cause : WebSocket delivery confirmations arrivaient avant messages locaux
- Solution : Pattern Optimistic UI dans chat_bloc.dart
  - Création message temporaire immédiate
  - Ajout à la liste AVANT requête HTTP
  - Remplacement par message serveur à la réponse
- Fichier : lib/presentation/state_management/chat_bloc.dart

## Implémentation TODOs (13/21)

### Social (social_header_widget.dart)
-  Copier lien du post dans presse-papiers
-  Partage natif via Share.share()
-  Dialogue de signalement avec 5 raisons

### Partage (share_post_dialog.dart)
-  Interface sélection d'amis avec checkboxes
-  Partage externe via Share API

### Média (media_upload_service.dart)
-  Parsing JSON réponse backend
-  Méthode deleteMedia() pour suppression
-  Génération miniature vidéo

### Posts (create_post_dialog.dart, edit_post_dialog.dart)
-  Extraction URL depuis uploads
-  Documentation chargement médias

### Chat (conversations_screen.dart)
-  Navigation vers notifications
-  ConversationSearchDelegate pour recherche

## Nouveaux Fichiers

### Configuration
- build-prod.ps1 : Script build production avec dart-define
- lib/core/constants/env_config.dart : Gestion environnements

### Documentation
- TODOS_IMPLEMENTED.md : Documentation complète TODOs

## Améliorations

### Architecture
- Refactoring injection de dépendances
- Amélioration routing et navigation
- Optimisation providers (UserProvider, FriendsProvider)

### UI/UX
- Amélioration thème et couleurs
- Optimisation animations
- Meilleure gestion erreurs

### Services
- Configuration API avec env_config
- Amélioration datasources (events, users)
- Optimisation modèles de données
This commit is contained in:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../../core/constants/design_system.dart';
import '../../core/constants/env_config.dart';
import '../../core/constants/urls.dart';
import '../../data/datasources/user_remote_data_source.dart';
@@ -130,6 +131,12 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
return;
}
// VALIDATION: Empêcher l'utilisateur de s'ajouter lui-même comme ami
if (friendId == _currentUserId) {
_showError('Vous ne pouvez pas vous ajouter vous-même comme ami');
return;
}
String? friendEmail;
try {
@@ -214,24 +221,24 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(theme),
const SizedBox(height: 16),
const SizedBox(height: DesignSystem.spacingMd),
_buildSearchField(theme),
const SizedBox(height: 16),
const SizedBox(height: DesignSystem.spacingMd),
if (_errorMessage != null) _buildErrorMessage(theme),
if (_isSearching) _buildLoadingIndicator(theme),
if (!_isSearching && _searchResults.isNotEmpty)
_buildSearchResults(theme),
const SizedBox(height: 16),
const SizedBox(height: DesignSystem.spacingMd),
_buildActions(theme),
],
),
@@ -244,24 +251,25 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
return Row(
children: [
Icon(
Icons.person_add,
Icons.person_add_rounded,
color: theme.colorScheme.primary,
size: 28,
size: 24,
),
const SizedBox(width: 12),
const SizedBox(width: DesignSystem.spacingMd),
Expanded(
child: Text(
'Ajouter un ami',
style: theme.textTheme.titleLarge?.copyWith(
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
),
IconButton(
icon: const Icon(Icons.close),
icon: const Icon(Icons.close_rounded, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
);
@@ -273,10 +281,18 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
controller: _searchController,
decoration: InputDecoration(
hintText: 'Entrez l\'email de l\'ami',
prefixIcon: const Icon(Icons.person_search),
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 14,
),
prefixIcon: Icon(
Icons.search_rounded,
size: 20,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(Icons.clear_rounded, size: 18),
onPressed: () {
_searchController.clear();
setState(() {
@@ -287,10 +303,19 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
)
: null,
helperText: 'Recherchez un utilisateur par son adresse email',
helperStyle: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingMd,
vertical: DesignSystem.spacingSm,
),
),
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 14),
onChanged: (value) {
setState(() {
_errorMessage = null;
@@ -321,24 +346,25 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
/// Construit le message d'erreur
Widget _buildErrorMessage(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Row(
children: [
Icon(
Icons.error_outline,
Icons.error_outline_rounded,
color: theme.colorScheme.error,
size: 20,
size: 18,
),
const SizedBox(width: 8),
const SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Text(
_errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontSize: 13,
),
),
),
@@ -349,10 +375,10 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
/// Construit l'indicateur de chargement
Widget _buildLoadingIndicator(ThemeData theme) {
return const Padding(
padding: EdgeInsets.all(24),
return Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Center(
child: CircularProgressIndicator(),
child: CircularProgressIndicator(strokeWidth: 2.5),
),
);
}
@@ -360,10 +386,10 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
/// Construit les résultats de recherche
Widget _buildSearchResults(ThemeData theme) {
if (_isSearching) {
return const Padding(
padding: EdgeInsets.all(24),
return Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Center(
child: CircularProgressIndicator(),
child: CircularProgressIndicator(strokeWidth: 2.5),
),
);
}
@@ -372,26 +398,36 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final user = _searchResults[index];
return _buildUserTile(theme, user);
},
),
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final user = _searchResults[index];
return _buildUserTile(theme, user);
},
);
}
/// Construit une tuile d'utilisateur
Widget _buildUserTile(ThemeData theme, UserModel user) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
margin: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
elevation: 0.5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
side: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingMd,
vertical: DesignSystem.spacingSm,
),
leading: CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: user.profileImageUrl.isNotEmpty &&
user.profileImageUrl.startsWith('http')
@@ -411,22 +447,32 @@ class _AddFriendDialogState extends State<AddFriendDialog> {
: '?',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: 16,
fontWeight: FontWeight.w600,
),
)
: null,
),
title: Text(
'${user.userFirstName} ${user.userLastName}'.trim(),
style: theme.textTheme.titleMedium,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
subtitle: Text(
user.email,
style: theme.textTheme.bodySmall,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
trailing: IconButton(
icon: const Icon(Icons.person_add),
icon: const Icon(Icons.person_add_rounded, size: 20),
onPressed: _isSearching ? null : () => _addFriend(user.userId),
tooltip: 'Ajouter comme ami',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
),
);

View File

@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
class AnimatedActionButton extends StatelessWidget {
final IconData icon;
final String label;
const AnimatedActionButton({
super.key,
required this.icon,
required this.label,
required this.icon, required this.label, super.key,
});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {

View File

@@ -0,0 +1,571 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/design_system.dart';
/// Widget bouton avec animation de scale au tap
///
/// Ajoute un effet de réduction de taille lors du tap,
/// avec feedback haptique optionnel.
///
/// **Usage:**
/// ```dart
/// AnimatedScaleButton(
/// onTap: () => print('Tapped'),
/// child: Container(
/// padding: EdgeInsets.all(16),
/// child: Text('Tap me'),
/// ),
/// )
/// ```
class AnimatedScaleButton extends StatefulWidget {
const AnimatedScaleButton({
required this.onTap,
required this.child,
this.scaleFactor = 0.95,
this.duration,
this.hapticFeedback = true,
super.key,
});
final VoidCallback? onTap;
final Widget child;
final double scaleFactor;
final Duration? duration;
final bool hapticFeedback;
@override
State<AnimatedScaleButton> createState() => _AnimatedScaleButtonState();
}
class _AnimatedScaleButtonState extends State<AnimatedScaleButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration ?? DesignSystem.durationFast,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: widget.scaleFactor,
).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveDecelerate,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
if (widget.onTap != null) {
_controller.forward();
if (widget.hapticFeedback) {
HapticFeedback.lightImpact();
}
}
}
void _handleTapUp(TapUpDetails details) {
if (widget.onTap != null) {
_controller.reverse();
}
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onTap,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
);
}
}
/// Widget avec animation de bounce au tap
///
/// Ajoute un effet de rebond élastique lors du tap.
class AnimatedBounceButton extends StatefulWidget {
const AnimatedBounceButton({
required this.onTap,
required this.child,
this.hapticFeedback = true,
super.key,
});
final VoidCallback? onTap;
final Widget child;
final bool hapticFeedback;
@override
State<AnimatedBounceButton> createState() => _AnimatedBounceButtonState();
}
class _AnimatedBounceButtonState extends State<AnimatedBounceButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: DesignSystem.durationMedium,
);
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(begin: 1.0, end: 0.9),
weight: 1,
),
TweenSequenceItem(
tween: Tween<double>(begin: 0.9, end: 1.1),
weight: 1,
),
TweenSequenceItem(
tween: Tween<double>(begin: 1.1, end: 1.0),
weight: 1,
),
]).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveBounce,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
if (widget.onTap != null) {
if (widget.hapticFeedback) {
HapticFeedback.mediumImpact();
}
_controller.forward(from: 0);
widget.onTap!();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
);
}
}
/// Widget avec fade in animation
///
/// Apparait en fondu avec translation optionnelle.
class FadeInWidget extends StatefulWidget {
const FadeInWidget({
required this.child,
this.duration,
this.delay = Duration.zero,
this.offset = const Offset(0, 20),
this.curve,
super.key,
});
final Widget child;
final Duration? duration;
final Duration delay;
final Offset offset;
final Curve? curve;
@override
State<FadeInWidget> createState() => _FadeInWidgetState();
}
class _FadeInWidgetState extends State<FadeInWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration ?? DesignSystem.durationMedium,
);
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: widget.curve ?? DesignSystem.curveDecelerate,
),
);
_slideAnimation = Tween<Offset>(
begin: widget.offset,
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: widget.curve ?? DesignSystem.curveDecelerate,
),
);
// Démarrer l'animation après le délai
Future.delayed(widget.delay, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacityAnimation,
child: Transform.translate(
offset: _slideAnimation.value,
child: widget.child,
),
);
}
}
/// Widget avec animation staggered pour listes
///
/// Anime chaque enfant avec un délai progressif.
class StaggeredListAnimation extends StatelessWidget {
const StaggeredListAnimation({
required this.children,
this.staggerDelay = const Duration(milliseconds: 50),
this.initialDelay = Duration.zero,
super.key,
});
final List<Widget> children;
final Duration staggerDelay;
final Duration initialDelay;
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(
children.length,
(index) => FadeInWidget(
delay: initialDelay + (staggerDelay * index),
child: children[index],
),
),
);
}
}
/// Card animée avec effet de élévation au hover
///
/// Augmente l'élévation et la taille au survol/tap.
class AnimatedCard extends StatefulWidget {
const AnimatedCard({
required this.child,
this.onTap,
this.elevation = 2.0,
this.hoverElevation = 8.0,
this.borderRadius,
this.padding,
this.margin,
super.key,
});
final Widget child;
final VoidCallback? onTap;
final double elevation;
final double hoverElevation;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
@override
State<AnimatedCard> createState() => _AnimatedCardState();
}
class _AnimatedCardState extends State<AnimatedCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _elevationAnimation;
late Animation<double> _scaleAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: DesignSystem.durationFast,
);
_elevationAnimation = Tween<double>(
begin: widget.elevation,
end: widget.hoverElevation,
).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveDecelerate,
),
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.02,
).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveDecelerate,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleHoverEnter() {
setState(() => _isHovered = true);
_controller.forward();
}
void _handleHoverExit() {
setState(() => _isHovered = false);
_controller.reverse();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
HapticFeedback.lightImpact();
}
void _handleTapUp(TapUpDetails details) {
if (!_isHovered) {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _handleHoverEnter(),
onExit: (_) => _handleHoverExit(),
child: GestureDetector(
onTapDown: widget.onTap != null ? _handleTapDown : null,
onTapUp: widget.onTap != null ? _handleTapUp : null,
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
margin: widget.margin,
child: Material(
elevation: _elevationAnimation.value,
borderRadius:
widget.borderRadius ?? DesignSystem.borderRadiusLg,
color: Theme.of(context).cardColor,
child: Container(
padding: widget.padding ??
DesignSystem.paddingAll(DesignSystem.spacingLg),
child: widget.child,
),
),
),
);
},
),
),
);
}
}
/// Loading button avec animation
///
/// Bouton qui affiche un loader pendant l'exécution d'une action async.
class AnimatedLoadingButton extends StatefulWidget {
const AnimatedLoadingButton({
required this.onPressed,
required this.child,
this.backgroundColor,
this.foregroundColor,
this.borderRadius,
this.padding,
this.height = 48,
super.key,
});
final Future<void> Function() onPressed;
final Widget child;
final Color? backgroundColor;
final Color? foregroundColor;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? padding;
final double height;
@override
State<AnimatedLoadingButton> createState() => _AnimatedLoadingButtonState();
}
class _AnimatedLoadingButtonState extends State<AnimatedLoadingButton> {
bool _isLoading = false;
Future<void> _handlePress() async {
if (_isLoading) return;
setState(() => _isLoading = true);
HapticFeedback.mediumImpact();
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedScaleButton(
onTap: _isLoading ? null : _handlePress,
child: AnimatedContainer(
duration: DesignSystem.durationMedium,
curve: DesignSystem.curveDecelerate,
height: widget.height,
width: _isLoading ? widget.height : null,
padding: _isLoading
? EdgeInsets.zero
: (widget.padding ??
DesignSystem.paddingHorizontal(DesignSystem.spacing2xl)),
decoration: BoxDecoration(
color: widget.backgroundColor ?? theme.colorScheme.primary,
borderRadius:
widget.borderRadius ?? DesignSystem.borderRadiusLg,
),
child: Center(
child: _isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
widget.foregroundColor ?? theme.colorScheme.onPrimary,
),
),
)
: DefaultTextStyle(
style: TextStyle(
color: widget.foregroundColor ?? theme.colorScheme.onPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
child: widget.child,
),
),
),
);
}
}
/// Pulse animation pour attirer l'attention
class PulseAnimation extends StatefulWidget {
const PulseAnimation({
required this.child,
this.duration = const Duration(milliseconds: 1000),
this.minScale = 0.95,
this.maxScale = 1.05,
super.key,
});
final Widget child;
final Duration duration;
final double minScale;
final double maxScale;
@override
State<PulseAnimation> createState() => _PulseAnimationState();
}
class _PulseAnimationState extends State<PulseAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
)..repeat(reverse: true);
_animation = Tween<double>(
begin: widget.minScale,
end: widget.maxScale,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _animation,
child: widget.child,
);
}
}

View File

@@ -4,9 +4,9 @@ import '../../../../../core/constants/colors.dart';
/// [AccountDeletionCard] est un widget permettant à l'utilisateur de supprimer son compte.
/// Il affiche une confirmation avant d'effectuer l'action de suppression.
class AccountDeletionCard extends StatelessWidget {
final BuildContext context;
const AccountDeletionCard({Key? key, required this.context}) : super(key: key);
const AccountDeletionCard({required this.context, super.key});
final BuildContext context;
@override
Widget build(BuildContext context) {
@@ -43,14 +43,14 @@ class AccountDeletionCard extends StatelessWidget {
actions: [
TextButton(
onPressed: () {
debugPrint("[LOG] Suppression du compte annulée.");
debugPrint('[LOG] Suppression du compte annulée.');
Navigator.of(context).pop();
},
child: Text('Annuler', style: TextStyle(color: AppColors.accentColor)),
),
TextButton(
onPressed: () {
debugPrint("[LOG] Suppression du compte confirmée.");
debugPrint('[LOG] Suppression du compte confirmée.');
Navigator.of(context).pop();
// Logique de suppression du compte ici.
},

View File

@@ -1,16 +1,22 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../../core/constants/colors.dart';
import '../../../../../core/utils/page_transitions.dart';
import '../../../../../data/providers/user_provider.dart';
import '../../screens/profile/edit_profile_screen.dart';
/// [EditOptionsCard] permet à l'utilisateur d'accéder aux options d'édition du profil,
/// incluant la modification du profil, la photo et le mot de passe.
/// Les interactions sont entièrement loguées pour une traçabilité complète.
class EditOptionsCard extends StatelessWidget {
const EditOptionsCard({Key? key}) : super(key: key);
const EditOptionsCard({super.key});
@override
Widget build(BuildContext context) {
debugPrint("[LOG] Initialisation de EditOptionsCard");
debugPrint('[LOG] Initialisation de EditOptionsCard');
final userProvider = Provider.of<UserProvider>(context, listen: false);
final user = userProvider.user;
return Card(
color: AppColors.cardColor.withOpacity(0.95),
@@ -24,25 +30,11 @@ class EditOptionsCard extends StatelessWidget {
context,
icon: Icons.edit,
label: 'Éditer le profil',
logMessage: "Édition du profil",
onTap: () => debugPrint("[LOG] Édition du profil activée."),
),
_buildDivider(),
_buildOption(
context,
icon: Icons.camera_alt,
label: 'Changer la photo de profil',
logMessage: "Changement de la photo de profil",
onTap: () =>
debugPrint("[LOG] Changement de la photo de profil activé."),
),
_buildDivider(),
_buildOption(
context,
icon: Icons.lock,
label: 'Changer le mot de passe',
logMessage: "Changement du mot de passe",
onTap: () => debugPrint("[LOG] Changement du mot de passe activé."),
logMessage: 'Édition du profil',
onTap: () {
debugPrint('[LOG] Édition du profil activée.');
context.pushFadeScale(EditProfileScreen(user: user));
},
),
],
),
@@ -59,13 +51,13 @@ class EditOptionsCard extends StatelessWidget {
}) {
return InkWell(
onTap: () {
debugPrint("[LOG] $logMessage");
debugPrint('[LOG] $logMessage');
onTap();
},
splashColor: AppColors.accentColor.withOpacity(0.3),
highlightColor: AppColors.accentColor.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: AppColors.accentColor),

View File

@@ -4,17 +4,14 @@ import '../../../../../core/constants/colors.dart';
/// [ExpandableSectionCard] est une carte qui peut s'étendre pour révéler des éléments enfants.
/// Ce composant inclut des animations d'extension, des logs pour chaque action et une expérience utilisateur optimisée.
class ExpandableSectionCard extends StatefulWidget {
const ExpandableSectionCard({
required this.title, required this.icon, required this.children, super.key,
});
final String title;
final IconData icon;
final List<Widget> children;
const ExpandableSectionCard({
Key? key,
required this.title,
required this.icon,
required this.children,
}) : super(key: key);
@override
_ExpandableSectionCardState createState() => _ExpandableSectionCardState();
}

View File

@@ -1,66 +1,251 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// [FriendCard] est un widget représentant une carte d'ami.
/// Cette carte inclut l'image de l'ami, son nom, et un bouton qui permet
/// d'interagir avec cette carte (via le `onTap`).
///
/// Ce widget est conçu pour être utilisé dans des listes d'amis, comme
/// dans la section "Mes Amis" de l'application.
class FriendCard extends StatelessWidget {
final String name; // Le nom de l'ami
final String imageUrl; // URL de l'image de profil de l'ami
final VoidCallback onTap; // Fonction callback exécutée lors d'un clic sur la carte
import '../../../core/constants/design_system.dart';
import '../../../core/utils/page_transitions.dart';
import '../../../data/datasources/chat_remote_data_source.dart';
import '../../../data/services/secure_storage.dart';
import '../../../domain/entities/friend.dart';
import '../../screens/chat/chat_screen.dart';
import '../animated_widgets.dart';
import '../custom_snackbar.dart';
import '../friend_detail_screen.dart';
import '../social_badge_widget.dart';
/// Constructeur de [FriendCard] avec des paramètres obligatoires.
const FriendCard({
Key? key,
required this.name,
required this.imageUrl,
required this.onTap,
}) : super(key: key);
/// [FriendCard] est un widget qui représente la carte d'un ami dans la liste.
/// Il affiche une image de profil, le nom, le statut, la dernière interaction,
/// un voyant pour l'état en ligne, et la durée de l'amitié.
class FriendCard extends StatefulWidget {
const FriendCard({required this.friend, super.key});
final Friend friend;
@override
State<FriendCard> createState() => _FriendCardState();
}
class _FriendCardState extends State<FriendCard> {
final ChatRemoteDataSource _chatDataSource = ChatRemoteDataSource(http.Client());
final SecureStorage _storage = SecureStorage();
bool _isCreatingConversation = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// Lorsque l'utilisateur clique sur la carte, on déclenche la fonction onTap.
debugPrint("[LOG] Carte de l'ami $name cliquée.");
onTap(); // Exécuter le callback fourni
},
child: Card(
elevation: 4, // Élévation de la carte pour donner un effet d'ombre
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)), // Bordure arrondie
color: Colors.grey.shade800, // Couleur de fond de la carte
child: Padding(
padding: const EdgeInsets.all(12.0), // Padding interne pour espacer le contenu
child: Row(
// Calcul de la durée de l'amitié
final duration = _calculateFriendshipDuration(widget.friend.dateAdded);
return AnimatedCard(
onTap: () => _navigateToFriendDetail(context),
elevation: DesignSystem.elevationSm,
hoverElevation: DesignSystem.elevationLg,
borderRadius: DesignSystem.borderRadiusLg,
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
child: Column(
children: [
Stack(
alignment: Alignment.topRight,
children: [
// Image de profil de l'ami affichée sous forme de cercle
Hero(
tag: name, // Le tag Hero permet de créer une transition animée vers un autre écran.
tag: 'friend_avatar_${widget.friend.friendId}',
child: CircleAvatar(
backgroundImage: NetworkImage(imageUrl), // Charger l'image depuis l'URL
radius: 30, // Taille de l'avatar
radius: 50,
backgroundImage: _getImageProvider(),
),
),
const SizedBox(width: 16), // Espacement entre l'image et le nom
// Le nom de l'ami avec un texte en gras et blanc
Expanded(
child: Text(
name,
style: const TextStyle(
fontSize: 18, // Taille de la police
color: Colors.white, // Couleur du texte
fontWeight: FontWeight.bold, // Style en gras
// Indicateur de statut en ligne
Positioned(
right: 0,
top: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: CircleAvatar(
key: ValueKey<bool>(widget.friend.isOnline ?? false),
radius: 8,
backgroundColor: (widget.friend.isOnline ?? false)
? Colors.green
: Colors.grey,
),
),
),
// Icône de flèche indiquant que la carte est cliquable
Icon(Icons.chevron_right, color: Colors.white70),
],
),
),
const SizedBox(height: 10),
Text(
'${widget.friend.friendFirstName} ${widget.friend.friendLastName}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 5),
Text(
widget.friend.status.name,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 5),
Text(
widget.friend.lastInteraction ?? 'Aucune interaction récente',
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
const SizedBox(height: 10),
// Affichage de la durée de l'amitié
Text(
'Amis depuis: $duration',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 10),
// Affichage des badges
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildBadges(),
),
const SizedBox(height: 8),
// Bouton de message compact
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isCreatingConversation ? null : _openChat,
icon: _isCreatingConversation
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chat_bubble_outline_rounded, size: 16),
label: Text(
_isCreatingConversation ? 'Envoi...' : 'Message',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 1,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
],
),
);
}
Future<void> _openChat() async {
final userId = await _storage.getUserId();
if (userId == null) {
if (mounted) {
context.showError('Erreur: Utilisateur non connecté');
}
return;
}
setState(() {
_isCreatingConversation = true;
});
try {
// Obtenir ou créer la conversation
final conversationModel = await _chatDataSource.getOrCreateConversation(
userId,
widget.friend.friendId,
);
if (mounted) {
setState(() {
_isCreatingConversation = false;
});
// Naviguer vers l'écran de chat
context.pushSlideUp(ChatScreen(conversation: conversationModel.toEntity()));
}
} catch (e) {
if (mounted) {
setState(() {
_isCreatingConversation = false;
});
context.showError('Erreur lors de l\'ouverture du chat');
}
}
}
/// Retourne l'image de l'ami, soit à partir d'une URL soit une image par défaut.
ImageProvider _getImageProvider() {
return widget.friend.imageUrl != null && widget.friend.imageUrl!.isNotEmpty
? (widget.friend.imageUrl!.startsWith('https')
? NetworkImage(widget.friend.imageUrl!) // Image depuis une URL
: AssetImage(widget.friend.imageUrl!) as ImageProvider) // Image locale
: const AssetImage('lib/assets/images/default_avatar.png'); // Image par défaut
}
/// Calcule la durée de l'amitié depuis la date d'ajout.
String _calculateFriendshipDuration(String? dateAdded) {
if (dateAdded == null || dateAdded.isEmpty) return 'Inconnu';
final date = DateTime.parse(dateAdded);
final duration = DateTime.now().difference(date);
if (duration.inDays < 30) {
return '${duration.inDays} jour${duration.inDays > 1 ? 's' : ''}';
} else if (duration.inDays < 365) {
final months = (duration.inDays / 30).floor();
return '$months mois';
} else {
final years = (duration.inDays / 365).floor();
return '$years an${years > 1 ? 's' : ''}';
}
}
/// Navigation vers l'écran de détails de l'ami
void _navigateToFriendDetail(BuildContext context) {
debugPrint(
"[LOG] Navigation : Détails de l'ami ${widget.friend.friendFirstName} ${widget.friend.friendLastName}",);
context.pushFadeScale(
FriendDetailScreen(
friendFirstName: widget.friend.friendFirstName,
friendLastName: widget.friend.friendLastName,
imageUrl: widget.friend.imageUrl ?? '',
friendId: widget.friend.friendId,
status: widget.friend.status,
lastInteraction: widget.friend.lastInteraction ?? 'Aucune',
dateAdded: widget.friend.dateAdded ?? 'Inconnu',
),
);
}
/// Crée une liste de badges à afficher sur la carte de l'ami.
List<Widget> _buildBadges() {
final List<Widget> badges = [];
// Exemple de badges en fonction des attributs de l'ami
if (widget.friend.isBestFriend != null && widget.friend.isBestFriend == true) {
badges.add(const BadgeWidget(
badge: 'Meilleur Ami',
icon: Icons.star,
),);
}
if (widget.friend.hasKnownSinceChildhood != null && widget.friend.hasKnownSinceChildhood == true) {
badges.add(const BadgeWidget(
badge: 'Ami d\'enfance',
icon: Icons.child_care,
),);
}
if (widget.friend.isOnline != null && widget.friend.isOnline == true) {
badges.add(const BadgeWidget(
badge: 'En ligne',
icon: Icons.circle,
),);
}
// Ajouter d'autres badges si nécessaire
return badges;
}
}

View File

@@ -6,9 +6,9 @@ import '../stat_tile.dart';
/// [StatisticsSectionCard] affiche les statistiques principales de l'utilisateur avec des animations.
/// Ce composant est optimisé pour une expérience interactive et une traçabilité complète des actions via les logs.
class StatisticsSectionCard extends StatelessWidget {
final User user;
const StatisticsSectionCard({Key? key, required this.user}) : super(key: key);
const StatisticsSectionCard({required this.user, super.key});
final User user;
@override
Widget build(BuildContext context) {
@@ -20,7 +20,7 @@ class StatisticsSectionCard extends StatelessWidget {
elevation: 5,
shadowColor: AppColors.darkPrimary.withOpacity(0.4),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -38,28 +38,28 @@ class StatisticsSectionCard extends StatelessWidget {
icon: Icons.event,
label: 'Événements Participés',
value: '${user.eventsCount}',
logMessage: "Affichage des événements participés : ${user.eventsCount}",
logMessage: 'Affichage des événements participés : ${user.eventsCount}',
),
_buildDivider(),
_buildAnimatedStatTile(
icon: Icons.place,
label: 'Établissements Visités',
value: '${user.visitedPlacesCount}',
logMessage: "Affichage des établissements visités : ${user.visitedPlacesCount}",
logMessage: 'Affichage des établissements visités : ${user.visitedPlacesCount}',
),
_buildDivider(),
_buildAnimatedStatTile(
icon: Icons.post_add,
label: 'Publications',
value: '${user.postsCount}',
logMessage: "Affichage des publications : ${user.postsCount}",
logMessage: 'Affichage des publications : ${user.postsCount}',
),
_buildDivider(),
_buildAnimatedStatTile(
icon: Icons.group,
label: 'Amis/Followers',
value: '${user.friendsCount}',
logMessage: "Affichage des amis/followers : ${user.friendsCount}",
logMessage: 'Affichage des amis/followers : ${user.friendsCount}',
),
],
),
@@ -74,7 +74,7 @@ class StatisticsSectionCard extends StatelessWidget {
required String value,
required String logMessage,
}) {
debugPrint("[LOG] $logMessage");
debugPrint('[LOG] $logMessage');
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 500),

View File

@@ -5,11 +5,11 @@ import '../../../../core/constants/colors.dart';
/// [SupportSectionCard] affiche les options de support et assistance.
/// Inclut des animations, du retour haptique, et des logs détaillés pour chaque action.
class SupportSectionCard extends StatelessWidget {
const SupportSectionCard({Key? key}) : super(key: key);
const SupportSectionCard({super.key});
@override
Widget build(BuildContext context) {
debugPrint("[LOG] Initialisation de SupportSectionCard.");
debugPrint('[LOG] Initialisation de SupportSectionCard.');
return Card(
color: AppColors.cardColor.withOpacity(0.95),
@@ -17,7 +17,7 @@ class SupportSectionCard extends StatelessWidget {
elevation: 6,
shadowColor: AppColors.darkPrimary.withOpacity(0.4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -35,7 +35,7 @@ class SupportSectionCard extends StatelessWidget {
context,
icon: Icons.help_outline,
label: 'Support et Assistance',
logMessage: "Accès au Support et Assistance.",
logMessage: 'Accès au Support et Assistance.',
),
_buildDivider(),
_buildOption(
@@ -49,7 +49,7 @@ class SupportSectionCard extends StatelessWidget {
context,
icon: Icons.privacy_tip_outlined,
label: 'Politique de confidentialité',
logMessage: "Accès à la politique de confidentialité.",
logMessage: 'Accès à la politique de confidentialité.',
),
],
),
@@ -67,13 +67,13 @@ class SupportSectionCard extends StatelessWidget {
return InkWell(
onTap: () {
HapticFeedback.lightImpact(); // Retour haptique léger
debugPrint("[LOG] $logMessage");
debugPrint('[LOG] $logMessage');
// Ajout de la navigation ou de l'action ici.
},
splashColor: AppColors.accentColor.withOpacity(0.3),
highlightColor: AppColors.cardColor.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Icon(icon, color: AppColors.accentColor, size: 28),

View File

@@ -7,9 +7,9 @@ import '../../../data/providers/user_provider.dart';
/// [UserInfoCard] affiche les informations essentielles de l'utilisateur de manière concise.
/// Conçu pour minimiser les répétitions tout en garantissant une expérience utilisateur fluide.
class UserInfoCard extends StatelessWidget {
final User user;
const UserInfoCard({Key? key, required this.user}) : super(key: key);
const UserInfoCard({required this.user, super.key});
final User user;
@override
Widget build(BuildContext context) {
@@ -21,29 +21,32 @@ class UserInfoCard extends StatelessWidget {
elevation: 5,
shadowColor: AppColors.darkPrimary.withOpacity(0.4),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 600),
tween: Tween<double>(begin: 0, end: 1),
curve: Curves.elasticOut,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
child: CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(user.profileImageUrl),
backgroundColor: Colors.transparent,
onBackgroundImageError: (error, stackTrace) {
debugPrint("[ERROR] Erreur de chargement de l'image de profil : $error");
},
child: child,
),
);
},
child: Icon(Icons.person, size: 50, color: Colors.grey.shade300),
Hero(
tag: 'user_profile_avatar_${user.userId}',
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 600),
tween: Tween<double>(begin: 0, end: 1),
curve: Curves.elasticOut,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
child: CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(user.profileImageUrl),
backgroundColor: Colors.transparent,
onBackgroundImageError: (error, stackTrace) {
debugPrint("[ERROR] Erreur de chargement de l'image de profil : $error");
},
child: child,
),
);
},
child: Icon(Icons.person, size: 50, color: Colors.grey.shade300),
),
),
const SizedBox(height: 10),
Text(

View File

@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/design_system.dart';
import '../../core/utils/calculate_time_ago.dart';
import '../../data/datasources/social_remote_data_source.dart';
import '../../data/services/secure_storage.dart';
import '../../domain/entities/comment.dart';
import 'animated_widgets.dart';
import 'custom_snackbar.dart';
import 'shimmer_loading.dart';
/// Bottom sheet moderne pour afficher et ajouter des commentaires.
///
/// Ce widget affiche tous les commentaires d'un post et permet
/// d'ajouter de nouveaux commentaires avec une interface élégante.
///
/// **Usage:**
/// ```dart
/// showCommentsBottomSheet(
/// context: context,
/// postId: '123',
/// );
/// ```
class CommentsBottomSheet extends StatefulWidget {
const CommentsBottomSheet({
required this.postId,
required this.onCommentAdded,
super.key,
});
/// ID du post
final String postId;
/// Callback appelé quand un commentaire est ajouté
final VoidCallback onCommentAdded;
/// Méthode statique pour afficher le bottom sheet
static Future<void> show({
required BuildContext context,
required String postId,
required VoidCallback onCommentAdded,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CommentsBottomSheet(
postId: postId,
onCommentAdded: onCommentAdded,
),
);
}
@override
State<CommentsBottomSheet> createState() => _CommentsBottomSheetState();
}
class _CommentsBottomSheetState extends State<CommentsBottomSheet> {
final SocialRemoteDataSource _dataSource = SocialRemoteDataSource(http.Client());
final SecureStorage _secureStorage = SecureStorage();
final TextEditingController _commentController = TextEditingController();
final FocusNode _commentFocusNode = FocusNode();
List<Comment> _comments = [];
bool _isLoading = true;
bool _isSubmitting = false;
String? _currentUserId;
@override
void initState() {
super.initState();
_loadComments();
_loadCurrentUser();
}
@override
void dispose() {
_commentController.dispose();
_commentFocusNode.dispose();
super.dispose();
}
Future<void> _loadCurrentUser() async {
final userId = await _secureStorage.getUserId();
if (mounted) {
setState(() {
_currentUserId = userId;
});
}
}
Future<void> _loadComments() async {
setState(() {
_isLoading = true;
});
try {
final comments = await _dataSource.getComments(widget.postId);
if (mounted) {
setState(() {
_comments = comments.map((model) => model.toEntity()).toList();
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
context.showError('Erreur lors du chargement des commentaires');
}
}
}
Future<void> _submitComment() async {
final content = _commentController.text.trim();
if (content.isEmpty) {
context.showWarning('Le commentaire ne peut pas être vide');
return;
}
if (_currentUserId == null) {
context.showWarning('Vous devez être connecté pour commenter');
return;
}
setState(() {
_isSubmitting = true;
});
try {
final newComment = await _dataSource.createComment(
postId: widget.postId,
content: content,
userId: _currentUserId!,
);
if (mounted) {
setState(() {
_comments.insert(0, newComment.toEntity());
_isSubmitting = false;
_commentController.clear();
});
_commentFocusNode.unfocus();
widget.onCommentAdded();
context.showSuccess('Commentaire ajouté');
}
} catch (e) {
if (mounted) {
setState(() {
_isSubmitting = false;
});
context.showError('Erreur lors de l\'ajout du commentaire');
}
}
}
Future<void> _deleteComment(Comment comment, int index) async {
try {
await _dataSource.deleteComment(widget.postId, comment.id);
if (mounted) {
setState(() {
_comments.removeAt(index);
});
widget.onCommentAdded();
context.showSuccess('Commentaire supprimé');
}
} catch (e) {
if (mounted) {
context.showError('Erreur lors de la suppression');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return Container(
height: mediaQuery.size.height * 0.75,
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
child: Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.symmetric(vertical: DesignSystem.spacingSm),
width: 36,
height: 3,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
),
// Header
Padding(
padding: DesignSystem.paddingHorizontal(DesignSystem.spacingLg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Commentaires',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
Text(
'${_comments.length}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 14,
),
),
],
),
),
Divider(
height: 20,
thickness: 1,
color: theme.dividerColor.withOpacity(0.5),
),
// Liste des commentaires
Expanded(
child: _isLoading
? const SkeletonList(
itemCount: 3,
skeletonWidget: ListItemSkeleton(),
)
: _comments.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline_rounded,
size: 56,
color: theme.colorScheme.onSurface.withOpacity(0.2),
),
const SizedBox(height: DesignSystem.spacingMd),
Text(
'Aucun commentaire',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
'Soyez le premier à commenter',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 13,
),
),
],
),
)
: ListView.separated(
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
itemCount: _comments.length,
separatorBuilder: (context, index) => Divider(
height: 20,
thickness: 1,
color: theme.dividerColor.withOpacity(0.3),
),
itemBuilder: (context, index) {
final comment = _comments[index];
return _buildCommentItem(comment, index, theme);
},
),
),
// Input pour ajouter un commentaire
Container(
padding: EdgeInsets.only(
left: DesignSystem.spacingLg,
right: DesignSystem.spacingLg,
top: DesignSystem.spacingMd,
bottom: mediaQuery.viewInsets.bottom + DesignSystem.spacingMd,
),
decoration: BoxDecoration(
color: theme.cardColor,
border: Border(
top: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
focusNode: _commentFocusNode,
decoration: InputDecoration(
hintText: 'Ajouter un commentaire...',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
borderSide: BorderSide.none,
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.5),
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingMd,
vertical: DesignSystem.spacingSm,
),
),
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 14),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
enabled: !_isSubmitting,
),
),
const SizedBox(width: DesignSystem.spacingSm),
AnimatedScaleButton(
onTap: _isSubmitting ? () {} : _submitComment,
child: Container(
padding: const EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: _isSubmitting
? theme.colorScheme.primary.withOpacity(0.5)
: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: _isSubmitting
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: Icon(
Icons.send_rounded,
color: theme.colorScheme.onPrimary,
size: 18,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildCommentItem(Comment comment, int index, ThemeData theme) {
final isCurrentUser = comment.userId == _currentUserId;
return FadeInWidget(
delay: Duration(milliseconds: index * 50),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
CircleAvatar(
radius: 18,
backgroundImage: comment.userProfileImageUrl.isNotEmpty
? NetworkImage(comment.userProfileImageUrl)
: null,
backgroundColor: theme.colorScheme.primary.withOpacity(0.15),
child: comment.userProfileImageUrl.isEmpty
? Icon(
Icons.person_rounded,
color: theme.colorScheme.primary,
size: 18,
)
: null,
),
const SizedBox(width: DesignSystem.spacingMd),
// Contenu du commentaire
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom et timestamp
Row(
children: [
Expanded(
child: Text(
comment.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
Text(
calculateTimeAgo(comment.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 12,
),
),
],
),
const SizedBox(height: 4),
// Contenu
Text(
comment.content,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.4,
),
),
],
),
),
// Bouton supprimer (si c'est le commentaire de l'utilisateur)
if (isCurrentUser) ...[
const SizedBox(width: DesignSystem.spacingSm),
IconButton(
icon: const Icon(Icons.delete_outline_rounded, size: 18),
color: theme.colorScheme.error.withOpacity(0.7),
onPressed: () => _deleteComment(comment, index),
tooltip: 'Supprimer',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
],
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'dart:io';
import 'package:camerawesome/camerawesome_plugin.dart';
// import 'package:camerawesome/camerawesome_plugin.dart'; // Désactivé - incompatible avec Flutter 3.24.3
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
@@ -19,60 +19,47 @@ class _CreateStoryPageState extends State<CreateStoryPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true, // Permet à l'AppBar de passer en mode transparent
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('Créer une nouvelle story'),
backgroundColor: Colors.transparent, // Transparence
elevation: 0, // Pas d'ombre pour l'en-tête
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColors.onPrimary), // Couleur adaptative
icon: Icon(Icons.arrow_back, color: AppColors.onPrimary),
onPressed: () {
Navigator.of(context).pop(); // Bouton retour
Navigator.of(context).pop();
},
),
),
body: Stack(
children: [
CameraAwesomeBuilder.awesome(
saveConfig: SaveConfig.photoAndVideo(
photoPathBuilder: (sensors) async {
final sensor = sensors.first; // Utilisation du premier capteur
final Directory extDir = await getTemporaryDirectory();
final Directory testDir = await Directory('${extDir.path}/camerawesome').create(recursive: true);
final String filePath = '${testDir.path}/${DateTime.now().millisecondsSinceEpoch}.jpg';
return SingleCaptureRequest(filePath, sensor); // CaptureRequest pour la photo
},
videoPathBuilder: (sensors) async {
final sensor = sensors.first; // Utilisation du premier capteur
final Directory extDir = await getTemporaryDirectory();
final Directory testDir = await Directory('${extDir.path}/camerawesome').create(recursive: true);
final String filePath = '${testDir.path}/${DateTime.now().millisecondsSinceEpoch}.mp4';
return SingleCaptureRequest(filePath, sensor); // CaptureRequest pour la vidéo
},
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 100,
color: AppColors.textSecondary,
),
sensorConfig: SensorConfig.single(
sensor: Sensor.position(SensorPosition.back), // Configuration correcte du capteur
const SizedBox(height: 20),
Text(
'Fonctionnalité caméra temporairement indisponible',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 16,
),
textAlign: TextAlign.center,
),
onMediaTap: (mediaCapture) async {
final captureRequest = mediaCapture.captureRequest;
if (captureRequest is SingleCaptureRequest) {
final filePath = captureRequest.path; // Accès au chemin de fichier
if (filePath != null) {
logger.i('Média capturé : $filePath');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Média sauvegardé à $filePath'),
backgroundColor: AppColors.accentColor, // Couleur adaptative du snack bar
));
} else {
logger.e('Erreur : Aucun fichier capturé.');
}
} else {
logger.e('Erreur : Capture non reconnue.');
}
},
),
],
const SizedBox(height: 10),
Text(
'Package camerawesome incompatible avec Flutter 3.24.3',
style: TextStyle(
color: AppColors.textSecondary.withOpacity(0.6),
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
);
}

View File

@@ -1,30 +1,53 @@
import 'package:flutter/material.dart';
/// AppBar personnalisée avec support du thème et design moderne.
///
/// Ce widget fournit une AppBar cohérente avec le thème de l'application
/// et des options de personnalisation.
///
/// **Usage:**
/// ```dart
/// CustomAppBar(
/// title: 'Mon Écran',
/// actions: [
/// IconButton(icon: Icon(Icons.search), onPressed: () {}),
/// ],
/// )
/// ```
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
const CustomAppBar({
required this.title,
super.key,
this.actions = const [],
this.leading,
this.automaticallyImplyLeading = true,
this.centerTitle,
this.elevation,
});
final String title;
final List<Widget> actions;
const CustomAppBar({
Key? key,
required this.title,
this.actions = const [],
}) : super(key: key);
final Widget? leading;
final bool automaticallyImplyLeading;
final bool? centerTitle;
final double? elevation;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
backgroundColor: Colors.black,
title: Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
title: Text(title),
actions: actions,
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
centerTitle: centerTitle,
elevation: elevation ?? 0,
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
);
}
@override
Size get preferredSize => const Size.fromHeight(56.0);
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -1,28 +1,263 @@
import 'package:flutter/material.dart';
/// Bouton personnalisé moderne et élégant.
///
/// Design épuré avec des proportions optimisées pour une application professionnelle.
/// Supporte Material 3 avec des variantes tonal, outlined et text.
///
/// **Usage:**
/// ```dart
/// CustomButton(
/// text: 'Confirmer',
/// onPressed: () {},
/// variant: ButtonVariant.primary,
/// size: ButtonSize.medium,
/// )
/// ```
class CustomButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
const CustomButton({
Key? key,
required this.text,
required this.onPressed,
}) : super(key: key);
super.key,
this.icon,
this.isLoading = false,
this.isEnabled = true,
this.variant = ButtonVariant.primary,
this.size = ButtonSize.medium,
this.fullWidth = false,
});
final String text;
final VoidCallback onPressed;
final IconData? icon;
final bool isLoading;
final bool isEnabled;
final ButtonVariant variant;
final ButtonSize size;
final bool fullWidth;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
textStyle: const TextStyle(fontSize: 18),
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
child: Text(text),
final theme = Theme.of(context);
final isDisabled = !isEnabled || isLoading;
final buttonStyle = _getButtonStyle(theme, variant, size);
final textStyle = _getTextStyle(theme, variant, size);
Widget child = isLoading
? SizedBox(
height: size == ButtonSize.small ? 14 : (size == ButtonSize.medium ? 16 : 18),
width: size == ButtonSize.small ? 14 : (size == ButtonSize.medium ? 16 : 18),
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
_getLoadingColor(theme, variant),
),
),
)
: Row(
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: _getIconSize(size)),
SizedBox(width: size == ButtonSize.small ? 6 : 8),
],
Text(text, style: textStyle),
],
);
Widget button;
switch (variant) {
case ButtonVariant.primary:
button = ElevatedButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: child,
);
break;
case ButtonVariant.tonal:
button = FilledButton.tonal(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: child,
);
break;
case ButtonVariant.outlined:
button = OutlinedButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: child,
);
break;
case ButtonVariant.text:
button = TextButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: child,
);
break;
}
return fullWidth ? SizedBox(width: double.infinity, child: button) : button;
}
ButtonStyle _getButtonStyle(
ThemeData theme,
ButtonVariant variant,
ButtonSize size,
) {
final padding = _getPadding(size);
final borderRadius = BorderRadius.circular(_getBorderRadius(size));
final elevation = variant == ButtonVariant.primary ? 2.0 : 0.0;
switch (variant) {
case ButtonVariant.primary:
return ElevatedButton.styleFrom(
padding: padding,
elevation: elevation,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
case ButtonVariant.tonal:
return FilledButton.styleFrom(
padding: padding,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
case ButtonVariant.outlined:
return OutlinedButton.styleFrom(
padding: padding,
side: BorderSide(
color: theme.colorScheme.outline,
width: 1.5,
),
shape: RoundedRectangleBorder(borderRadius: borderRadius),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
case ButtonVariant.text:
return TextButton.styleFrom(
padding: padding,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}
TextStyle _getTextStyle(
ThemeData theme,
ButtonVariant variant,
ButtonSize size,
) {
final fontSize = _getFontSize(size);
final fontWeight = size == ButtonSize.small ? FontWeight.w500 : FontWeight.w600;
Color textColor;
switch (variant) {
case ButtonVariant.primary:
textColor = theme.colorScheme.onPrimary;
break;
case ButtonVariant.tonal:
textColor = theme.colorScheme.onSecondaryContainer;
break;
case ButtonVariant.outlined:
case ButtonVariant.text:
textColor = theme.colorScheme.primary;
break;
}
return TextStyle(
fontSize: fontSize,
fontWeight: fontWeight,
color: textColor,
letterSpacing: 0.1,
);
}
Color _getLoadingColor(ThemeData theme, ButtonVariant variant) {
switch (variant) {
case ButtonVariant.primary:
return theme.colorScheme.onPrimary;
case ButtonVariant.tonal:
return theme.colorScheme.onSecondaryContainer;
case ButtonVariant.outlined:
case ButtonVariant.text:
return theme.colorScheme.primary;
}
}
EdgeInsets _getPadding(ButtonSize size) {
switch (size) {
case ButtonSize.small:
return const EdgeInsets.symmetric(horizontal: 12, vertical: 6);
case ButtonSize.medium:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 10);
case ButtonSize.large:
return const EdgeInsets.symmetric(horizontal: 20, vertical: 12);
}
}
double _getFontSize(ButtonSize size) {
switch (size) {
case ButtonSize.small:
return 13;
case ButtonSize.medium:
return 14;
case ButtonSize.large:
return 15;
}
}
double _getIconSize(ButtonSize size) {
switch (size) {
case ButtonSize.small:
return 16;
case ButtonSize.medium:
return 18;
case ButtonSize.large:
return 20;
}
}
double _getBorderRadius(ButtonSize size) {
switch (size) {
case ButtonSize.small:
return 8;
case ButtonSize.medium:
return 10;
case ButtonSize.large:
return 12;
}
}
}
/// Variantes de boutons disponibles (Material 3).
enum ButtonVariant {
/// Bouton principal élevé (filled, elevated)
primary,
/// Bouton tonal (filled tonal - Material 3)
tonal,
/// Bouton avec bordure (outlined)
outlined,
/// Bouton texte sans fond (text)
text,
}
/// Tailles de boutons disponibles.
enum ButtonSize {
/// Petite taille - compact
small,
/// Taille moyenne - standard
medium,
/// Grande taille - proéminente
large,
}

View File

@@ -1,47 +1,157 @@
import 'package:flutter/material.dart';
/// Drawer personnalisé avec navigation et design moderne.
///
/// Ce widget fournit un drawer de navigation avec des options
/// stylisées et cohérentes avec le thème de l'application.
///
/// **Usage:**
/// ```dart
/// Scaffold(
/// drawer: CustomDrawer(),
/// body: MyContent(),
/// )
/// ```
class CustomDrawer extends StatelessWidget {
const CustomDrawer({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
const DrawerHeader(
decoration: BoxDecoration(
color: Colors.blueAccent,
child: Column(
children: [
_buildHeader(theme),
Expanded(
child: _buildMenuItems(context, theme),
),
_buildFooter(theme),
],
),
);
}
/// Construit l'en-tête du drawer.
Widget _buildHeader(ThemeData theme) {
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.secondary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event,
size: 48,
color: theme.colorScheme.onPrimary,
),
const SizedBox(height: 12),
Text(
'AfterWork',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
child: Text(
'AfterWork',
style: TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
ListTile(
leading: Icon(Icons.home),
title: Text('Accueil'),
onTap: () {
Navigator.pushNamed(context, '/home');
},
),
ListTile(
leading: Icon(Icons.event),
title: Text('Événements'),
onTap: () {
Navigator.pushNamed(context, '/event');
},
),
ListTile(
leading: Icon(Icons.camera_alt), // Icône mise à jour pour la story
title: Text('Story'),
onTap: () {
Navigator.pushNamed(context, '/story');
},
),
],
),
);
}
/// Construit les éléments du menu.
Widget _buildMenuItems(BuildContext context, ThemeData theme) {
return ListView(
padding: EdgeInsets.zero,
children: [
_buildMenuItem(
context,
theme,
icon: Icons.home,
title: 'Accueil',
route: '/home',
),
_buildMenuItem(
context,
theme,
icon: Icons.event,
title: 'Événements',
route: '/event',
),
_buildMenuItem(
context,
theme,
icon: Icons.camera_alt,
title: 'Story',
route: '/story',
),
_buildMenuItem(
context,
theme,
icon: Icons.people,
title: 'Amis',
route: '/friends',
),
_buildMenuItem(
context,
theme,
icon: Icons.settings,
title: 'Paramètres',
route: '/settings',
),
],
);
}
/// Construit un élément de menu.
Widget _buildMenuItem(
BuildContext context,
ThemeData theme, {
required IconData icon,
required String title,
required String route,
}) {
return ListTile(
leading: Icon(
icon,
color: theme.colorScheme.primary,
),
title: Text(title),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, route);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
);
}
/// Construit le pied de page du drawer.
Widget _buildFooter(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Text(
'Version 1.0.0',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
);
}
}

View File

@@ -1,31 +1,105 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/colors.dart';
/// Widget de liste personnalisé avec support du thème et animations.
///
/// Ce widget fournit un élément de liste cohérent avec le design system,
/// utilisant automatiquement les couleurs du thème actif.
///
/// **Usage:**
/// ```dart
/// CustomListTile(
/// icon: Icons.settings,
/// label: 'Paramètres',
/// onTap: () {
/// // Action
/// },
/// )
/// ```
class CustomListTile extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
/// Crée un nouveau [CustomListTile].
///
/// [icon] L'icône à afficher à gauche
/// [label] Le texte à afficher
/// [onTap] La fonction à exécuter lors du clic
/// [trailing] Un widget optionnel à afficher à droite
/// [subtitle] Un sous-titre optionnel
/// [iconColor] Couleur personnalisée pour l'icône (optionnel)
const CustomListTile({
Key? key,
required this.icon,
required this.label,
required this.onTap,
}) : super(key: key);
super.key,
this.trailing,
this.subtitle,
this.iconColor,
});
/// L'icône à afficher à gauche
final IconData icon;
/// Le texte à afficher
final String label;
/// La fonction à exécuter lors du clic
final VoidCallback? onTap;
/// Un widget optionnel à afficher à droite
final Widget? trailing;
/// Un sous-titre optionnel
final String? subtitle;
/// Couleur personnalisée pour l'icône (optionnel)
final Color? iconColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEnabled = onTap != null;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
splashColor: Colors.blueAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
child: ListTile(
leading: Icon(icon, color: AppColors.accentColor),
leading: CircleAvatar(
backgroundColor: (iconColor ?? theme.colorScheme.primary)
.withOpacity(0.1),
child: Icon(
icon,
color: iconColor ?? theme.colorScheme.primary,
size: 20,
),
),
title: Text(
label,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isEnabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.38),
),
),
subtitle: subtitle != null
? Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
)
: null,
trailing: trailing ??
(isEnabled
? Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurface.withOpacity(0.5),
)
: null),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
);
}
}

View File

@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
/// Types de SnackBar selon le contexte
enum SnackBarType {
success,
error,
warning,
info,
}
/// Affiche un SnackBar personnalisé avec style moderne
///
/// **Usage:**
/// ```dart
/// showCustomSnackBar(
/// context,
/// message: 'Événement créé avec succès',
/// type: SnackBarType.success,
/// );
/// ```
void showCustomSnackBar(
BuildContext context, {
required String message,
SnackBarType type = SnackBarType.info,
Duration duration = const Duration(seconds: 3),
String? actionLabel,
VoidCallback? onActionPressed,
}) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// Couleurs selon le type
Color backgroundColor;
Color textColor;
IconData icon;
switch (type) {
case SnackBarType.success:
backgroundColor = const Color(0xFF4CAF50);
textColor = Colors.white;
icon = Icons.check_circle;
break;
case SnackBarType.error:
backgroundColor = const Color(0xFFF44336);
textColor = Colors.white;
icon = Icons.error;
break;
case SnackBarType.warning:
backgroundColor = const Color(0xFFFF9800);
textColor = Colors.white;
icon = Icons.warning;
break;
case SnackBarType.info:
backgroundColor = isDark ? theme.colorScheme.surface : Colors.grey[800]!;
textColor = Colors.white;
icon = Icons.info;
break;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(icon, color: textColor, size: DesignSystem.iconSizeMd),
DesignSystem.horizontalSpace(DesignSystem.spacingMd),
Expanded(
child: Text(
message,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: backgroundColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: DesignSystem.borderRadiusMd,
),
margin: DesignSystem.paddingAll(DesignSystem.spacingLg),
duration: duration,
action: actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: textColor,
onPressed: onActionPressed ?? () {},
)
: null,
),
);
}
/// Toast flottant avec animation
///
/// Alternative élégante au SnackBar, s'affiche en haut de l'écran.
class CustomToast {
static OverlayEntry? _overlayEntry;
static bool _isVisible = false;
/// Affiche un toast personnalisé
static void show(
BuildContext context, {
required String message,
SnackBarType type = SnackBarType.info,
Duration duration = const Duration(seconds: 2),
ToastPosition position = ToastPosition.top,
}) {
if (_isVisible) {
hide();
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// Couleurs selon le type
Color backgroundColor;
Color textColor;
IconData icon;
switch (type) {
case SnackBarType.success:
backgroundColor = const Color(0xFF4CAF50);
textColor = Colors.white;
icon = Icons.check_circle;
break;
case SnackBarType.error:
backgroundColor = const Color(0xFFF44336);
textColor = Colors.white;
icon = Icons.error;
break;
case SnackBarType.warning:
backgroundColor = const Color(0xFFFF9800);
textColor = Colors.white;
icon = Icons.warning;
break;
case SnackBarType.info:
backgroundColor =
isDark ? theme.colorScheme.surface : Colors.grey[800]!;
textColor = Colors.white;
icon = Icons.info;
break;
}
_overlayEntry = OverlayEntry(
builder: (context) => _ToastWidget(
message: message,
backgroundColor: backgroundColor,
textColor: textColor,
icon: icon,
position: position,
onDismiss: hide,
),
);
final overlay = Overlay.of(context);
overlay.insert(_overlayEntry!);
_isVisible = true;
// Auto-dismiss après la durée spécifiée
Future.delayed(duration, () {
hide();
});
}
/// Cache le toast
static void hide() {
if (_isVisible && _overlayEntry != null) {
_overlayEntry!.remove();
_overlayEntry = null;
_isVisible = false;
}
}
}
/// Position du toast
enum ToastPosition {
top,
center,
bottom,
}
/// Widget interne pour afficher le toast avec animation
class _ToastWidget extends StatefulWidget {
const _ToastWidget({
required this.message,
required this.backgroundColor,
required this.textColor,
required this.icon,
required this.position,
required this.onDismiss,
});
final String message;
final Color backgroundColor;
final Color textColor;
final IconData icon;
final ToastPosition position;
final VoidCallback onDismiss;
@override
State<_ToastWidget> createState() => _ToastWidgetState();
}
class _ToastWidgetState extends State<_ToastWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: DesignSystem.durationMedium,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveDecelerate,
),
);
// Animation de slide selon la position
Offset beginOffset;
switch (widget.position) {
case ToastPosition.top:
beginOffset = const Offset(0, -1);
break;
case ToastPosition.center:
beginOffset = const Offset(0, 0);
break;
case ToastPosition.bottom:
beginOffset = const Offset(0, 1);
break;
}
_slideAnimation = Tween<Offset>(
begin: beginOffset,
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: DesignSystem.curveDecelerate,
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Position selon l'énumération
Alignment alignment;
EdgeInsets margin;
switch (widget.position) {
case ToastPosition.top:
alignment = Alignment.topCenter;
margin = EdgeInsets.only(
top: MediaQuery.of(context).padding.top + DesignSystem.spacingLg,
);
break;
case ToastPosition.center:
alignment = Alignment.center;
margin = EdgeInsets.zero;
break;
case ToastPosition.bottom:
alignment = Alignment.bottomCenter;
margin = EdgeInsets.only(
bottom:
MediaQuery.of(context).padding.bottom + DesignSystem.spacingLg,
);
break;
}
return Positioned.fill(
child: Align(
alignment: alignment,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: GestureDetector(
onTap: () {
_controller.reverse().then((_) => widget.onDismiss());
},
child: Container(
margin: margin +
DesignSystem.paddingHorizontal(DesignSystem.spacingLg),
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: DesignSystem.borderRadiusMd,
boxShadow: DesignSystem.shadowLg,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
color: widget.textColor,
size: DesignSystem.iconSizeMd,
),
DesignSystem.horizontalSpace(DesignSystem.spacingMd),
Flexible(
child: Text(
widget.message,
style: TextStyle(
color: widget.textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
),
),
);
}
}
/// Bottom sheet personnalisé avec design moderne
Future<T?> showCustomBottomSheet<T>({
required BuildContext context,
required Widget child,
bool isDismissible = true,
bool enableDrag = true,
Color? backgroundColor,
double? initialChildSize,
double? minChildSize,
double? maxChildSize,
}) {
final theme = Theme.of(context);
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
isDismissible: isDismissible,
enableDrag: enableDrag,
backgroundColor: Colors.transparent,
builder: (context) => DraggableScrollableSheet(
initialChildSize: initialChildSize ?? 0.6,
minChildSize: minChildSize ?? 0.3,
maxChildSize: maxChildSize ?? 0.9,
builder: (context, scrollController) => Container(
decoration: BoxDecoration(
color: backgroundColor ?? theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusXl),
),
boxShadow: DesignSystem.shadowXl,
),
child: Column(
children: [
// Handle pour indiquer qu'on peut glisser
Container(
margin: DesignSystem.paddingVertical(DesignSystem.spacingMd),
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: DesignSystem.borderRadiusRound,
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
child: child,
),
),
],
),
),
),
);
}
/// Dialog personnalisé avec animation élégante
Future<T?> showCustomDialog<T>({
required BuildContext context,
required Widget child,
bool barrierDismissible = true,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black54,
transitionDuration: DesignSystem.durationMedium,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(
scale: Tween<double>(
begin: 0.8,
end: 1.0,
).animate(
CurvedAnimation(
parent: animation,
curve: DesignSystem.curveDecelerate,
),
),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
return Center(
child: Container(
margin: DesignSystem.paddingAll(DesignSystem.spacing2xl),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: DesignSystem.borderRadiusXl,
boxShadow: DesignSystem.shadowXl,
),
child: Material(
color: Colors.transparent,
child: child,
),
),
);
},
);
}
/// Extensions pour faciliter l'utilisation
extension SnackBarExtensions on BuildContext {
/// Affiche un snackbar de succès
void showSuccess(String message) {
showCustomSnackBar(
this,
message: message,
type: SnackBarType.success,
);
}
/// Affiche un snackbar d'erreur
void showError(String message) {
showCustomSnackBar(
this,
message: message,
type: SnackBarType.error,
);
}
/// Affiche un snackbar d'avertissement
void showWarning(String message) {
showCustomSnackBar(
this,
message: message,
type: SnackBarType.warning,
);
}
/// Affiche un snackbar d'information
void showInfo(String message) {
showCustomSnackBar(
this,
message: message,
type: SnackBarType.info,
);
}
/// Affiche un toast de succès
void toastSuccess(String message) {
CustomToast.show(
this,
message: message,
type: SnackBarType.success,
);
}
/// Affiche un toast d'erreur
void toastError(String message) {
CustomToast.show(
this,
message: message,
type: SnackBarType.error,
);
}
}

View File

@@ -1,65 +1,154 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../core/utils/date_formatter.dart';
/// Champ de sélection de date avec design moderne et support du thème.
///
/// Ce widget fournit un champ de sélection de date cohérent avec le design system,
/// utilisant automatiquement les couleurs du thème actif.
///
/// **Usage:**
/// ```dart
/// DatePickerField(
/// label: 'Date de début',
/// selectedDate: selectedDate,
/// onDatePicked: (date) {
/// setState(() => selectedDate = date);
/// },
/// )
/// ```
class DatePickerField extends StatelessWidget {
final DateTime? selectedDate;
final Function(DateTime?) onDatePicked;
final String label; // Texte du label
/// Crée un nouveau [DatePickerField].
///
/// [onDatePicked] La fonction appelée lorsqu'une date est sélectionnée
/// [selectedDate] La date actuellement sélectionnée (optionnel)
/// [label] Le texte du label (par défaut: 'Sélectionnez une date')
/// [firstDate] La première date sélectionnable (par défaut: aujourd'hui)
/// [lastDate] La dernière date sélectionnable (par défaut: 2101)
/// [initialDate] La date initiale affichée (par défaut: aujourd'hui ou selectedDate)
/// [helpText] Texte d'aide affiché dans le picker (optionnel)
const DatePickerField({
Key? key,
this.selectedDate,
required this.onDatePicked,
this.label = 'Sélectionnez une date', // Label par défaut
}) : super(key: key);
super.key,
this.selectedDate,
this.label = 'Sélectionnez une date',
this.firstDate,
this.lastDate,
this.initialDate,
this.helpText,
});
/// La date actuellement sélectionnée
final DateTime? selectedDate;
/// La fonction appelée lorsqu'une date est sélectionnée
final Function(DateTime) onDatePicked;
/// Le texte du label
final String label;
/// La première date sélectionnable
final DateTime? firstDate;
/// La dernière date sélectionnable
final DateTime? lastDate;
/// La date initiale affichée dans le picker
final DateTime? initialDate;
/// Texte d'aide affiché dans le picker
final String? helpText;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime(2101),
);
if (picked != null) {
onDatePicked(picked);
}
},
final theme = Theme.of(context);
final hasDate = selectedDate != null;
return InkWell(
onTap: () => _showDatePicker(context),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14.0, horizontal: 18.0),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.1), // Fond plus doux et moderne
borderRadius: BorderRadius.circular(12.0), // Coins arrondis plus prononcés
border: Border.all(color: Colors.blueGrey.withOpacity(0.5), width: 2.0), // Bordure légère
boxShadow: [
BoxShadow(
color: Colors.black12,
offset: Offset(0, 4),
blurRadius: 8,
),
],
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasDate
? theme.colorScheme.primary
: theme.colorScheme.outline.withOpacity(0.5),
width: hasDate ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedDate == null
? label
: '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}',
style: const TextStyle(
color: Colors.blueGrey, // Couleur du texte adaptée
fontSize: 16.0, // Taille de police améliorée
fontWeight: FontWeight.w600, // Poids de police plus important pour un meilleur contraste
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasDate)
Text(
'Date sélectionnée',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 4),
Text(
hasDate
? DateFormatter.formatDateShort(selectedDate!)
: label,
style: theme.textTheme.bodyLarge?.copyWith(
color: hasDate
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: hasDate ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
const SizedBox(width: 12),
Icon(
Icons.calendar_today,
color: Colors.blueGrey, // Couleur de l'icône assortie au texte
color: hasDate
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 24,
),
],
),
),
);
}
/// Affiche le sélecteur de date.
Future<void> _showDatePicker(BuildContext context) async {
final theme = Theme.of(context);
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initialDate ?? selectedDate ?? now,
firstDate: firstDate ?? now,
lastDate: lastDate ?? DateTime(2101),
helpText: helpText,
builder: (context, child) {
return Theme(
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
primary: theme.colorScheme.primary,
),
),
child: child!,
);
},
);
if (picked != null) {
onDatePicked(picked);
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
import '../../core/utils/date_formatter.dart';
/// Widget pour afficher un séparateur de date entre groupes de messages.
///
/// Ce widget affiche une date formatée de manière intelligente
/// (ex: "Aujourd'hui", "Hier", "Lundi", "12 janvier") pour séparer
/// visuellement les groupes de messages par jour.
class DateSeparator extends StatelessWidget {
const DateSeparator({
required this.date,
super.key,
});
final DateTime date;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: DesignSystem.spacingMd),
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
),
child: Text(
ChatDateFormatter.formatDateSeparator(date),
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

View File

@@ -1,8 +1,40 @@
import 'package:flutter/material.dart';
import 'package:afterwork/core/utils/date_formatter.dart';
import '../../core/utils/date_formatter.dart';
import 'event_menu.dart';
/// En-tête d'événement avec informations du créateur et actions.
///
/// Ce widget affiche les informations du créateur de l'événement,
/// la date, le lieu, et des actions (menu, fermeture).
///
/// **Usage:**
/// ```dart
/// EventHeader(
/// creatorFirstName: 'John',
/// creatorLastName: 'Doe',
/// profileImageUrl: 'https://example.com/avatar.jpg',
/// eventDate: '2024-01-01',
/// location: 'Paris, France',
/// menuKey: menuKey,
/// menuContext: context,
/// onClose: () => handleClose(),
/// )
/// ```
class EventHeader extends StatelessWidget {
const EventHeader({
required this.creatorFirstName,
required this.creatorLastName,
required this.profileImageUrl,
required this.location,
required this.menuKey,
required this.menuContext,
required this.onClose,
this.eventDate,
this.imageUrl,
super.key,
});
final String creatorFirstName;
final String creatorLastName;
final String profileImageUrl;
@@ -11,113 +43,170 @@ class EventHeader extends StatelessWidget {
final String location;
final GlobalKey menuKey;
final BuildContext menuContext;
final VoidCallback onClose; // Ajout d'un callback pour l'action de fermeture
const EventHeader({
Key? key,
required this.creatorFirstName,
required this.creatorLastName,
required this.profileImageUrl,
this.eventDate,
this.imageUrl,
required this.location,
required this.menuKey,
required this.menuContext,
required this.onClose, // Initialisation du callback de fermeture
}) : super(key: key);
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
DateTime? date;
try {
date = DateTime.parse(eventDate ?? '');
} catch (e) {
date = null;
}
String formattedDate = date != null ? DateFormatter.formatDate(date) : 'Date inconnue';
final theme = Theme.of(context);
final formattedDate = _formatDate();
return Stack(
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.grey.shade800,
backgroundImage: profileImageUrl.isNotEmpty
? NetworkImage(profileImageUrl)
: AssetImage(profileImageUrl) as ImageProvider,
radius: 22,
),
const SizedBox(width: 8),
_buildAvatar(theme),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$creatorFirstName $creatorLastName',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
formattedDate,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
location.isNotEmpty ? location : 'Lieu non spécifié',
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
),
],
child: _buildCreatorInfo(theme, formattedDate),
),
],
),
_buildActions(theme),
],
);
}
/// Construit l'avatar du créateur.
Widget _buildAvatar(ThemeData theme) {
return CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.surfaceVariant,
backgroundImage: _getImageProvider(),
onBackgroundImageError: (exception, stackTrace) {
// Gestion silencieuse de l'erreur
},
child: profileImageUrl.isEmpty
? Icon(
Icons.person,
color: theme.colorScheme.onSurfaceVariant,
size: 20,
)
: null,
);
}
/// Obtient le provider d'image.
ImageProvider? _getImageProvider() {
if (profileImageUrl.isEmpty) return null;
if (profileImageUrl.startsWith('http://') ||
profileImageUrl.startsWith('https://')) {
return NetworkImage(profileImageUrl);
}
return AssetImage(profileImageUrl);
}
/// Construit les informations du créateur.
Widget _buildCreatorInfo(ThemeData theme, String formattedDate) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$creatorFirstName $creatorLastName',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.calendar_today,
size: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Expanded(
child: Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Placement des icônes avec padding pour éviter qu'elles ne soient trop proches du bord
Positioned(
top: 0,
right: -5,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: menuKey,
icon: const Icon(Icons.more_vert, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: () {
showEventOptions(menuContext, menuKey);
},
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.location_on,
size: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Expanded(
child: Text(
location.isNotEmpty ? location : 'Lieu non spécifié',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 0), // Espacement entre les icônes
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: onClose, // Appel du callback de fermeture
),
],
),
),
],
),
],
);
}
/// Construit les actions (menu et fermeture).
Widget _buildActions(ThemeData theme) {
return Positioned(
top: 0,
right: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: menuKey,
icon: Icon(
Icons.more_vert,
color: theme.colorScheme.onSurface.withOpacity(0.6),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
onPressed: () {
showEventOptions(menuContext, menuKey);
},
),
IconButton(
icon: Icon(
Icons.close,
color: theme.colorScheme.onSurface.withOpacity(0.6),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
onPressed: onClose,
),
],
),
);
}
/// Formate la date de l'événement.
String _formatDate() {
if (eventDate == null || eventDate!.isEmpty) {
return 'Date inconnue';
}
try {
final date = DateTime.parse(eventDate!);
return DateFormatter.formatDate(date);
} catch (e) {
return 'Date invalide';
}
}
}

View File

@@ -1,39 +1,144 @@
import 'package:flutter/material.dart';
class EventImage extends StatelessWidget {
final String? imageUrl;
final double aspectRatio;
import 'fullscreen_image_viewer.dart';
const EventImage({Key? key, this.imageUrl, this.aspectRatio = 16 / 9}) : super(key: key);
/// Widget pour afficher l'image d'un événement avec placeholder.
///
/// Ce widget gère l'affichage des images d'événements avec un ratio d'aspect
/// configurable et un placeholder en cas d'erreur ou d'absence d'image.
/// L'image peut être cliquée pour l'afficher en plein écran avec Hero animation.
///
/// **Usage:**
/// ```dart
/// EventImage(
/// imageUrl: 'https://example.com/image.jpg',
/// heroTag: 'event_image_123',
/// aspectRatio: 16 / 9,
/// )
/// ```
class EventImage extends StatelessWidget {
const EventImage({
super.key,
this.imageUrl,
this.heroTag,
this.eventTitle,
this.aspectRatio = 16 / 9,
});
/// URL de l'image à afficher
final String? imageUrl;
/// Tag Hero pour l'animation (doit être unique)
final String? heroTag;
/// Titre de l'événement (affiché dans la vue plein écran)
final String? eventTitle;
/// Ratio d'aspect de l'image (largeur/hauteur)
final double aspectRatio;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: AspectRatio(
aspectRatio: aspectRatio,
child: imageUrl != null && imageUrl!.isNotEmpty
? Image.network(
imageUrl!,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholderImage();
},
)
: _buildPlaceholderImage(),
final theme = Theme.of(context);
// Si pas d'image ou pas de heroTag, afficher simplement sans Hero
if (imageUrl == null || imageUrl!.isEmpty || heroTag == null) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: aspectRatio,
child: _buildImage(theme),
),
);
}
// Avec Hero animation et navigation vers fullscreen
return GestureDetector(
onTap: () => _openFullscreen(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: aspectRatio,
child: Hero(
tag: heroTag!,
child: _buildImage(theme),
),
),
),
);
}
Widget _buildPlaceholderImage() {
/// Ouvre l'image en plein écran
void _openFullscreen(BuildContext context) {
Navigator.push(
context,
PageRouteBuilder(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenImageViewer(
imageUrl: imageUrl!,
heroTag: heroTag!,
title: eventTitle,
),
);
},
),
);
}
/// Construit l'image ou le placeholder.
Widget _buildImage(ThemeData theme) {
if (imageUrl == null || imageUrl!.isEmpty) {
return _buildPlaceholder(theme);
}
return Image.network(
imageUrl!,
width: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return _buildLoadingPlaceholder(theme, loadingProgress);
},
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholder(theme);
},
);
}
/// Construit le placeholder.
Widget _buildPlaceholder(ThemeData theme) {
return Container(
color: Colors.grey[800],
color: theme.colorScheme.surfaceVariant,
child: Center(
child: Icon(
Icons.image_not_supported,
color: Colors.grey[400],
size: 50,
Icons.image_not_supported_outlined,
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
size: 40,
),
),
);
}
/// Construit le placeholder de chargement.
Widget _buildLoadingPlaceholder(
ThemeData theme,
ImageChunkEvent loadingProgress,
) {
final progress = loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null;
return Container(
color: theme.colorScheme.surfaceVariant,
child: Center(
child: CircularProgressIndicator(
value: progress,
color: theme.colorScheme.primary,
),
),
);

View File

@@ -1,6 +1,32 @@
import 'package:flutter/material.dart';
/// Widget d'interaction avec un événement (réagir, commenter, partager).
///
/// Ce widget affiche des boutons compacts pour interagir avec un événement,
/// avec compteurs et design moderne.
///
/// **Usage:**
/// ```dart
/// EventInteractionRow(
/// onReact: () => handleReact(),
/// onComment: () => handleComment(),
/// onShare: () => handleShare(),
/// reactionsCount: 10,
/// commentsCount: 5,
/// sharesCount: 2,
/// )
/// ```
class EventInteractionRow extends StatelessWidget {
const EventInteractionRow({
required this.onReact,
required this.onComment,
required this.onShare,
required this.reactionsCount,
required this.commentsCount,
required this.sharesCount,
super.key,
});
final VoidCallback onReact;
final VoidCallback onComment;
final VoidCallback onShare;
@@ -8,36 +34,82 @@ class EventInteractionRow extends StatelessWidget {
final int commentsCount;
final int sharesCount;
const EventInteractionRow({
Key? key,
required this.onReact,
required this.onComment,
required this.onShare,
required this.reactionsCount,
required this.commentsCount,
required this.sharesCount,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildIconButton(Icons.thumb_up_alt_outlined, 'Réagir', reactionsCount, onReact),
_buildIconButton(Icons.comment_outlined, 'Commenter', commentsCount, onComment),
_buildIconButton(Icons.share_outlined, 'Partager', sharesCount, onShare),
_buildInteractionButton(
context,
theme,
icon: Icons.thumb_up_outlined,
label: 'Réagir',
count: reactionsCount,
onPressed: onReact,
),
_buildInteractionButton(
context,
theme,
icon: Icons.comment_outlined,
label: 'Commenter',
count: commentsCount,
onPressed: onComment,
),
_buildInteractionButton(
context,
theme,
icon: Icons.share_outlined,
label: 'Partager',
count: sharesCount,
onPressed: onShare,
),
],
);
}
Widget _buildIconButton(IconData icon, String label, int count, VoidCallback onPressed) {
return TextButton.icon(
onPressed: onPressed,
icon: Icon(icon, color: const Color(0xFF1DBF73), size: 18),
label: Text(
'$label ($count)',
style: const TextStyle(color: Colors.white70, fontSize: 12),
/// Construit un bouton d'interaction compact.
Widget _buildInteractionButton(
BuildContext context,
ThemeData theme, {
required IconData icon,
required String label,
required int count,
required VoidCallback onPressed,
}) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
_formatCount(count),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
/// Formate le compteur avec format compact.
String _formatCount(int count) {
if (count >= 1000) {
return '${(count / 1000).toStringAsFixed(1)}k';
}
return count.toString();
}
}

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:afterwork/data/models/event_model.dart';
import '../../core/utils/app_logger.dart';
import '../../data/models/event_model.dart';
import '../screens/event/event_card.dart';
class EventList extends StatelessWidget {
final List<EventModel> events;
const EventList({Key? key, required this.events}) : super(key: key);
const EventList({required this.events, super.key});
final List<EventModel> events;
@override
Widget build(BuildContext context) {
@@ -37,31 +38,31 @@ class EventList extends StatelessWidget {
// Gestion des actions
void _handleReact(EventModel event) {
print('Réaction ajoutée à l\'événement ${event.title}');
AppLogger.d('Réaction ajoutée à l\'événement ${event.title}', tag: 'EventList');
}
void _handleComment(EventModel event) {
print('Commentaire ajouté à l\'événement ${event.title}');
AppLogger.d('Commentaire ajouté à l\'événement ${event.title}', tag: 'EventList');
}
void _handleShare(EventModel event) {
print('Événement partagé : ${event.title}');
AppLogger.i('Événement partagé : ${event.title}', tag: 'EventList');
}
void _handleParticipate(EventModel event) {
print('Participation confirmée à l\'événement ${event.title}');
AppLogger.i('Participation confirmée à l\'événement ${event.title}', tag: 'EventList');
}
void _handleCloseEvent(EventModel event) {
print('Événement ${event.title} fermé');
AppLogger.i('Événement ${event.title} fermé', tag: 'EventList');
}
void _handleReopenEvent(EventModel event) {
print('Événement ${event.title} réouvert');
AppLogger.i('Événement ${event.title} réouvert', tag: 'EventList');
}
void _handleRemoveEvent(EventModel event) {
print('Événement ${event.title} retiré');
AppLogger.i('Événement ${event.title} retiré', tag: 'EventList');
}

View File

@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:afterwork/core/constants/colors.dart';
import 'package:provider/provider.dart';
import '../../core/constants/colors.dart';
import '../../core/theme/theme_provider.dart';
import '../../core/utils/app_logger.dart';
void showEventOptions(BuildContext context, GlobalKey key) {
// Obtient la position de l'élément pour afficher le menu contextuel
@@ -27,7 +28,7 @@ void showEventOptions(BuildContext context, GlobalKey key) {
label: 'Voir les détails',
color: AppColors.primary, // Utilise la couleur primaire dynamique
onTap: () {
print('Voir les détails');
AppLogger.d('Voir les détails', tag: 'EventMenu');
// Log d'action pour suivre l'interaction utilisateur
},
),
@@ -36,7 +37,7 @@ void showEventOptions(BuildContext context, GlobalKey key) {
label: 'Modifier l\'événement',
color: AppColors.secondary, // Utilise la couleur secondaire dynamique
onTap: () {
print('Modifier l\'événement');
AppLogger.d('Modifier l\'événement', tag: 'EventMenu');
},
),
_buildElegantMenuItem(
@@ -48,9 +49,9 @@ void showEventOptions(BuildContext context, GlobalKey key) {
},
),
],
elevation: 12.0, // Niveau d'élévation du menu pour une ombre modérée
elevation: 12, // Niveau d'élévation du menu pour une ombre modérée
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0), // Coins arrondis pour un look moderne
borderRadius: BorderRadius.circular(20), // Coins arrondis pour un look moderne
),
color: AppColors.customBackgroundColor, // Surface dynamique selon le thème
).then((value) {
@@ -132,7 +133,7 @@ void _showDeleteConfirmation(BuildContext context) {
actions: <Widget>[
TextButton(
style: ButtonStyle(
overlayColor: MaterialStateProperty.all(Colors.grey.shade200),
overlayColor: WidgetStateProperty.all(Colors.grey.shade200),
),
child: Text('Annuler', style: TextStyle(color: Colors.grey.shade700)),
onPressed: () {
@@ -145,14 +146,14 @@ void _showDeleteConfirmation(BuildContext context) {
backgroundColor: AppColors.errorColor, // Bouton de suppression en couleur d'erreur
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
borderRadius: BorderRadius.circular(8),
),
textStyle: TextStyle(fontWeight: FontWeight.bold),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
label: Text('Supprimer'),
label: const Text('Supprimer'),
onPressed: () {
Navigator.of(context).pop();
print('Événement supprimé');
AppLogger.i('Événement supprimé', tag: 'EventMenu');
// Logique de suppression réelle ici
},
),

View File

@@ -1,38 +1,80 @@
import 'package:flutter/material.dart';
/// Badge de statut d'événement avec design moderne et compact.
///
/// Ce widget affiche le statut d'un événement (ouvert, fermé, annulé)
/// avec une couleur et une icône appropriées.
///
/// **Usage:**
/// ```dart
/// EventStatusBadge(status: 'ouvert')
/// EventStatusBadge(status: 'fermé')
/// ```
class EventStatusBadge extends StatelessWidget {
const EventStatusBadge({
required this.status,
super.key,
});
/// Le statut de l'événement ('ouvert', 'fermé', 'annulé')
final String status;
const EventStatusBadge({Key? key, required this.status}) : super(key: key);
/// Retourne les propriétés du statut.
_StatusProperties get _properties {
final lowerStatus = status.toLowerCase();
switch (lowerStatus) {
case 'fermé':
return _StatusProperties(
color: Colors.red,
icon: Icons.lock,
label: 'Fermé',
);
case 'annulé':
return _StatusProperties(
color: Colors.orange,
icon: Icons.cancel,
label: 'Annulé',
);
case 'ouvert':
default:
return _StatusProperties(
color: Colors.green,
icon: Icons.lock_open,
label: 'Ouvert',
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final props = _properties;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status == 'fermé' ? Colors.red.withOpacity(0.2) : Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(12.0),
color: props.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: status == 'fermé' ? Colors.red : Colors.green,
width: 1.0,
color: props.color.withOpacity(0.5),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
status == 'fermé' ? Icons.lock : Icons.lock_open,
color: status == 'fermé' ? Colors.red : Colors.green,
size: 10.0,
props.icon,
color: props.color,
size: 12,
),
const SizedBox(width: 5),
const SizedBox(width: 4),
Text(
status == 'fermé' ? 'Fermé' : 'Ouvert',
style: TextStyle(
color: status == 'fermé' ? Colors.red : Colors.green,
fontSize: 10,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.bold,
props.label,
style: theme.textTheme.bodySmall?.copyWith(
color: props.color,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
@@ -40,3 +82,16 @@ class EventStatusBadge extends StatelessWidget {
);
}
}
/// Propriétés d'un statut d'événement.
class _StatusProperties {
const _StatusProperties({
required this.color,
required this.icon,
required this.label,
});
final Color color;
final IconData icon;
final String label;
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class AccessibilityField extends StatelessWidget {
final Function(String?) onSaved;
const AccessibilityField({Key? key, required this.onSaved}) : super(key: key);
const AccessibilityField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class AccommodationInfoField extends StatelessWidget {
final Function(String?) onSaved;
const AccommodationInfoField({Key? key, required this.onSaved}) : super(key: key);
const AccommodationInfoField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import '../../../core/utils/app_logger.dart';
/// Un champ pour saisir le nombre maximum de participants à un événement.
/// Il est conçu pour permettre à l'utilisateur de saisir un nombre entier.
class AttendeesField extends StatelessWidget {
// Définition de la fonction de rappel pour sauver la valeur saisie.
final Function(int) onSaved;
// Le constructeur prend une fonction de rappel pour sauvegarder la valeur saisie.
const AttendeesField({Key? key, required this.onSaved}) : super(key: key);
const AttendeesField({required this.onSaved, super.key});
// Définition de la fonction de rappel pour sauver la valeur saisie.
final Function(int) onSaved;
@override
Widget build(BuildContext context) {
@@ -33,21 +34,21 @@ class AttendeesField extends StatelessWidget {
filled: true, // Le champ est rempli avec une couleur de fond.
fillColor: Colors.blueGrey.withOpacity(0.1), // Couleur de fond du champ avec opacité.
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure arrondie
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure arrondie
borderSide: BorderSide.none, // Pas de bordure par défaut
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure de base
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure en bleu lors du focus
width: 2.0,
width: 2,
),
),
prefixIcon: const Icon(
@@ -57,13 +58,13 @@ class AttendeesField extends StatelessWidget {
),
style: const TextStyle(
color: Colors.blueGrey, // Couleur du texte saisi
fontSize: 16.0, // Taille de police
fontSize: 16, // Taille de police
fontWeight: FontWeight.w600, // Poids de la police pour la lisibilité
),
onChanged: (value) {
// Lors de chaque modification de texte, on tente de convertir la valeur en entier.
int? maxParticipants = int.tryParse(value) ?? 0; // Conversion en entier, avec une valeur par défaut de 0.
print('Nombre maximum de participants saisi : $maxParticipants'); // Log pour suivre la valeur saisie.
final int maxParticipants = int.tryParse(value) ?? 0; // Conversion en entier, avec une valeur par défaut de 0.
AppLogger.d('Nombre maximum de participants saisi : $maxParticipants', tag: 'AttendeesField');
onSaved(maxParticipants); // Appel de la fonction onSaved pour transmettre la valeur au formulaire principal.
},
validator: (value) {

View File

@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' as rootBundle;
class CategoryField extends StatefulWidget {
// Ce callback est utilisé pour enregistrer la valeur sélectionnée dans le formulaire
final FormFieldSetter<String> onSaved;
// Constructeur de la classe CategoryField
const CategoryField({Key? key, required this.onSaved}) : super(key: key);
const CategoryField({required this.onSaved, super.key});
// Ce callback est utilisé pour enregistrer la valeur sélectionnée dans le formulaire
final FormFieldSetter<String> onSaved;
@override
_CategoryFieldState createState() => _CategoryFieldState();
@@ -59,22 +59,22 @@ class _CategoryFieldState extends State<CategoryField> {
});
// Log pour vérifier si les catégories ont bien été chargées
debugPrint("Catégories chargées : $_categoryMap");
debugPrint('Catégories chargées : $_categoryMap');
} catch (e) {
// Log en cas d'erreur lors du chargement
debugPrint("Erreur lors du chargement des catégories : $e");
debugPrint('Erreur lors du chargement des catégories : $e');
// Affichage d'un message d'erreur à l'utilisateur si le chargement échoue
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'Erreur lors du chargement des catégories. Veuillez réessayer plus tard.')));
'Erreur lors du chargement des catégories. Veuillez réessayer plus tard.',),),);
}
}
/// Méthode pour construire la liste des éléments du menu déroulant avec les catégories et sous-catégories.
/// Cette méthode crée une liste d'éléments DropdownMenuItem pour afficher dans le DropdownButton.
List<DropdownMenuItem<String>> _buildDropdownItems() {
List<DropdownMenuItem<String>> items = [];
final List<DropdownMenuItem<String>> items = [];
// Parcours des catégories et ajout des sous-catégories dans le menu déroulant
_categoryMap.forEach((category, subcategories) {
@@ -95,12 +95,12 @@ class _CategoryFieldState extends State<CategoryField> {
);
// Ajouter les sous-catégories associées à cette catégorie
for (String subcategory in subcategories) {
for (final String subcategory in subcategories) {
items.add(
DropdownMenuItem<String>(
value: subcategory, // Valeur de la sous-catégorie
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
padding: const EdgeInsets.only(left: 16),
// Indentation pour les sous-catégories
child: Text(
subcategory,
@@ -113,7 +113,7 @@ class _CategoryFieldState extends State<CategoryField> {
});
// Log pour vérifier le nombre d'éléments créés pour le menu déroulant
debugPrint("Éléments créés pour le menu déroulant : ${items.length}");
debugPrint('Éléments créés pour le menu déroulant : ${items.length}');
return items;
}
@@ -124,7 +124,7 @@ class _CategoryFieldState extends State<CategoryField> {
return _dropdownItems.isEmpty
? const Center(
child:
CircularProgressIndicator()) // Affichage d'un indicateur de chargement pendant le chargement des données
CircularProgressIndicator(),) // Affichage d'un indicateur de chargement pendant le chargement des données
: DropdownButtonFormField<String>(
value: _selectedCategory,
// Valeur sélectionnée par l'utilisateur
@@ -138,29 +138,29 @@ class _CategoryFieldState extends State<CategoryField> {
fillColor: Colors.blueGrey.withOpacity(0.1),
// Couleur de fond
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide(
color: Colors.blueGrey, // Couleur de la bordure par défaut
width: 2.0, // Épaisseur de la bordure
width: 2, // Épaisseur de la bordure
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide(
color: Colors.blueGrey,
// Couleur de la bordure quand non sélectionné
width: 2.0,
width: 2,
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide(
color: Colors.blue, // Bordure quand le champ est sélectionné
width: 2.0,
width: 2,
),
),
prefixIcon: const Icon(Icons.category,
color: Colors.blueGrey), // Icône du champ
color: Colors.blueGrey,), // Icône du champ
),
style: const TextStyle(color: Colors.blueGrey),
// Style du texte sélectionné
@@ -172,7 +172,7 @@ class _CategoryFieldState extends State<CategoryField> {
// Liste des éléments du menu déroulant
onChanged: (String? newValue) {
// Log pour suivre la valeur sélectionnée
debugPrint("Nouvelle catégorie sélectionnée : $newValue");
debugPrint('Nouvelle catégorie sélectionnée : $newValue');
setState(() {
_selectedCategory =
@@ -185,7 +185,7 @@ class _CategoryFieldState extends State<CategoryField> {
'Veuillez choisir une catégorie',
// Texte affiché lorsqu'aucune catégorie n'est sélectionnée
style: TextStyle(
color: Colors.blueGrey), // Style du texte par défaut
color: Colors.blueGrey,), // Style du texte par défaut
),
);
}

View File

@@ -10,11 +10,11 @@ import 'package:flutter/material.dart';
/// - `onSaved`: Une fonction callback utilisée pour enregistrer la valeur du champ de texte une fois que le formulaire est soumis.
/// ```
class DescriptionField extends StatelessWidget {
// Callback utilisé pour enregistrer la valeur de la description
final FormFieldSetter<String> onSaved;
// Constructeur du widget DescriptionField
const DescriptionField({Key? key, required this.onSaved}) : super(key: key);
const DescriptionField({required this.onSaved, super.key});
// Callback utilisé pour enregistrer la valeur de la description
final FormFieldSetter<String> onSaved;
@override
Widget build(BuildContext context) {
@@ -31,27 +31,27 @@ class DescriptionField extends StatelessWidget {
hintStyle: const TextStyle(color: Colors.blueGrey),
hintText: 'Entrez un la description ici...',
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure arrondie améliorée
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure arrondie améliorée
borderSide: BorderSide.none, // Pas de bordure visible
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure de base en bleu gris
width: 2.0, // Largeur de la bordure
width: 2, // Largeur de la bordure
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure bleue lors du focus
width: 2.0, // Épaisseur de la bordure lors du focus
width: 2, // Épaisseur de la bordure lors du focus
),
),
prefixIcon: const Icon(Icons.description, color: Colors.blueGrey), // Icône de description avant le texte
),
// Style du texte dans le champ
style: const TextStyle(color: Colors.blueGrey, fontSize: 16.0),
style: const TextStyle(color: Colors.blueGrey, fontSize: 16),
// Limite le champ à 3 lignes, avec un retour à la ligne automatique
maxLines: 3,
// Autres configurations du champ

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
class LinkField extends StatelessWidget {
// Le callback `onSaved` est utilisé pour enregistrer la valeur du champ lorsque le formulaire est soumis.
final FormFieldSetter<String> onSaved;
// Constructeur de la classe LinkField, qui attend le callback `onSaved`.
const LinkField({Key? key, required this.onSaved}) : super(key: key);
const LinkField({required this.onSaved, super.key});
// Le callback `onSaved` est utilisé pour enregistrer la valeur du champ lorsque le formulaire est soumis.
final FormFieldSetter<String> onSaved;
@override
Widget build(BuildContext context) {
@@ -17,21 +17,21 @@ class LinkField extends StatelessWidget {
filled: true, // Remplissage du champ
fillColor: Colors.blueGrey.withOpacity(0.1), // Couleur de fond avec une légère opacité
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)), // Bords arrondis du champ
borderRadius: BorderRadius.all(Radius.circular(10)), // Bords arrondis du champ
borderSide: BorderSide.none, // Pas de bordure visible
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide(
color: Colors.blueGrey, // Couleur de la bordure quand non sélectionné
width: 2.0,
width: 2,
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide: BorderSide(
color: Colors.blue, // Bordure quand le champ est sélectionné
width: 2.0,
width: 2,
),
),
prefixIcon: const Icon(Icons.link, color: Colors.blueGrey), // Icône de lien à gauche
@@ -40,7 +40,7 @@ class LinkField extends StatelessWidget {
style: const TextStyle(color: Colors.blueGrey), // Style du texte saisi par l'utilisateur
onSaved: (value) {
// Log de la valeur du champ lorsqu'on l'enregistre
debugPrint("Lien enregistré : $value");
debugPrint('Lien enregistré : $value');
// Appel du callback `onSaved` pour enregistrer la valeur dans le formulaire
onSaved(value);
@@ -52,7 +52,7 @@ class LinkField extends StatelessWidget {
final Uri? uri = Uri.tryParse(value);
if (uri == null || !uri.hasAbsolutePath) {
// Log en cas d'erreur de validation
debugPrint("URL invalide : $value");
debugPrint('URL invalide : $value');
return 'Veuillez entrer un lien valide';
}
}

View File

@@ -13,13 +13,12 @@ import '../../screens/location/location_picker_Screen.dart';
/// - `onLocationPicked`: Un callback pour retourner la localisation choisie par l'utilisateur.
///
class LocationField extends StatelessWidget {
const LocationField({required this.location, required this.onLocationPicked, super.key, this.selectedLatLng});
final String location;
final LatLng? selectedLatLng;
final Function(LatLng?) onLocationPicked;
const LocationField({Key? key, required this.location, this.selectedLatLng, required this.onLocationPicked})
: super(key: key);
@override
Widget build(BuildContext context) {
// Log : Construction du champ LocationField
@@ -45,13 +44,13 @@ class LocationField extends StatelessWidget {
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300), // Animation fluide lors du focus
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.1), // Fond plus visible, subtilement coloré
borderRadius: BorderRadius.circular(12.0), // Bordure arrondie améliorée
borderRadius: BorderRadius.circular(12), // Bordure arrondie améliorée
border: Border.all(
color: selectedLatLng == null ? Colors.blueGrey.withOpacity(0.5) : Colors.blue, // Bordure change selon l'état
width: 2.0,
width: 2,
),
),
child: Row(
@@ -61,7 +60,7 @@ class LocationField extends StatelessWidget {
selectedLatLng == null
? 'Sélectionnez une localisation' // Message par défaut si aucune localisation sélectionnée
: 'Localisation: $location', // Affiche la localisation actuelle
style: const TextStyle(color: Colors.blueGrey, fontSize: 16.0),
style: const TextStyle(color: Colors.blueGrey, fontSize: 16),
),
const Icon(Icons.location_on, color: Colors.blueGrey),
],

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
/// Un champ de saisie pour l'organisateur, utilisé dans un formulaire.
class OrganizerField extends StatelessWidget {
// Fonction de rappel pour sauvegarder la valeur de l'organisateur.
final Function(String?) onSaved;
// Constructeur qui prend la fonction onSaved pour transmettre l'organisateur au formulaire.
const OrganizerField({Key? key, required this.onSaved}) : super(key: key);
const OrganizerField({required this.onSaved, super.key});
// Fonction de rappel pour sauvegarder la valeur de l'organisateur.
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {
@@ -21,21 +21,21 @@ class OrganizerField extends StatelessWidget {
color: Colors.blueGrey, // Couleur de l'icône.
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure arrondie.
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure arrondie.
borderSide: BorderSide.none, // Pas de bordure par défaut.
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure colorée en blueGrey.
width: 1.5, // Largeur de la bordure.
),
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure bleue au focus.
width: 2.0,
width: 2,
),
),
filled: true, // Le champ de saisie est rempli de couleur de fond.

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class ParkingField extends StatelessWidget {
final Function(String?) onSaved;
const ParkingField({Key? key, required this.onSaved}) : super(key: key);
const ParkingField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class ParticipationFeeField extends StatelessWidget {
final Function(String?) onSaved;
const ParticipationFeeField({Key? key, required this.onSaved}) : super(key: key);
const ParticipationFeeField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class PrivacyRulesField extends StatelessWidget {
final Function(String?) onSaved;
const PrivacyRulesField({Key? key, required this.onSaved}) : super(key: key);
const PrivacyRulesField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class SecurityProtocolField extends StatelessWidget {
final Function(String?) onSaved;
const SecurityProtocolField({Key? key, required this.onSaved}) : super(key: key);
const SecurityProtocolField({required this.onSaved, super.key});
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import '../../../core/utils/app_logger.dart';
/// Un champ permettant à l'utilisateur de saisir des tags.
/// Il permet également d'afficher les tags saisis sous forme de chips (étiquettes).
class TagsField extends StatefulWidget {
// Fonction de rappel pour sauvegarder la liste des tags saisis.
final Function(List<String>) onSaved;
// Constructeur qui prend la fonction onSaved pour transmettre les tags au formulaire.
const TagsField({Key? key, required this.onSaved}) : super(key: key);
const TagsField({required this.onSaved, super.key});
// Fonction de rappel pour sauvegarder la liste des tags saisis.
final Function(List<String>) onSaved;
@override
_TagsFieldState createState() => _TagsFieldState(); // Création de l'état pour gérer les tags.
@@ -37,39 +38,39 @@ class _TagsFieldState extends State<TagsField> {
color: Colors.blueGrey, // Couleur de l'icône.
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure arrondie.
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure arrondie.
borderSide: BorderSide.none, // Pas de bordure par défaut.
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure de base.
width: 1.5, // Largeur de la bordure.
),
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure bleue au focus.
width: 2.0,
width: 2,
),
),
filled: true, // Le champ est rempli avec une couleur de fond.
fillColor: Colors.blueGrey.withOpacity(0.1), // Couleur de fond du champ de texte avec opacité.
),
onFieldSubmitted: (value) {
print('Tags soumis : $value'); // Log pour suivre ce qui a été saisi avant la soumission.
AppLogger.d('Tags soumis : $value', tag: 'TagsField');
_addTags(value); // Appel à la méthode _addTags pour ajouter les tags.
},
),
const SizedBox(height: 8), // Espacement entre le champ de saisie et les chips.
Wrap(
spacing: 8.0, // Espacement entre les chips.
spacing: 8, // Espacement entre les chips.
children: _tags.map((tag) => Chip(
label: Text(tag), // Texte du tag à afficher.
backgroundColor: Colors.blueGrey.withOpacity(0.2), // Couleur de fond des chips.
labelStyle: const TextStyle(color: Colors.blueGrey), // Couleur du texte dans les chips.
)).toList(), // Génère une liste de Chips pour chaque tag.
),).toList(), // Génère une liste de Chips pour chaque tag.
),
],
);
@@ -83,7 +84,7 @@ class _TagsFieldState extends State<TagsField> {
.where((tag) => tag.isNotEmpty) // Exclut les tags vides.
.toList(); // Crée la liste de tags.
});
print('Tags ajoutés : $_tags'); // Log pour vérifier la liste de tags ajoutée.
AppLogger.d('Tags ajoutés : $_tags', tag: 'TagsField');
widget.onSaved(_tags); // Envoie la liste des tags au formulaire principal.
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
class TitleField extends StatelessWidget {
const TitleField({required this.onSaved, super.key});
final FormFieldSetter<String> onSaved;
const TitleField({Key? key, required this.onSaved}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -15,21 +15,21 @@ class TitleField extends StatelessWidget {
hintStyle: const TextStyle(color: Colors.blueGrey),
hintText: 'Entrez un le titre ici...',
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure plus arrondie
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure plus arrondie
borderSide: BorderSide.none, // Pas de bordure par défaut
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure de base
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: const BorderSide(
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure en bleu lors du focus
width: 2.0,
width: 2,
),
),
prefixIcon: const Icon(
@@ -39,7 +39,7 @@ class TitleField extends StatelessWidget {
),
style: const TextStyle(
color: Colors.blueGrey, // Texte en bleu pour un meilleur contraste
fontSize: 16.0, // Taille de police améliorée
fontSize: 16, // Taille de police améliorée
fontWeight: FontWeight.w600, // Poids de la police pour la lisibilité
),
validator: (value) {

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
/// Un champ de saisie pour les informations de transport, utilisé dans un formulaire.
class TransportInfoField extends StatelessWidget {
// Fonction de rappel pour sauvegarder les informations de transport.
final Function(String?) onSaved;
// Constructeur qui prend la fonction onSaved pour transmettre les informations de transport au formulaire.
const TransportInfoField({Key? key, required this.onSaved}) : super(key: key);
const TransportInfoField({required this.onSaved, super.key});
// Fonction de rappel pour sauvegarder les informations de transport.
final Function(String?) onSaved;
@override
Widget build(BuildContext context) {
@@ -21,21 +21,21 @@ class TransportInfoField extends StatelessWidget {
color: Colors.blueGrey, // Couleur de l'icône.
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)), // Bordure arrondie.
borderRadius: BorderRadius.all(Radius.circular(12)), // Bordure arrondie.
borderSide: BorderSide.none, // Pas de bordure par défaut.
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blueGrey, // Bordure colorée en blueGrey.
width: 1.5, // Largeur de la bordure.
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Colors.blue, // Bordure bleue au focus.
width: 2.0,
width: 2,
),
),
filled: true, // Le champ de saisie est rempli de couleur de fond.

View File

@@ -99,7 +99,7 @@ class FriendDetailScreen extends StatelessWidget {
children: [
// Animation Hero pour une transition fluide lors de la navigation
Hero(
tag: friendId,
tag: 'friend_avatar_$friendId',
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,

View File

@@ -21,94 +21,146 @@ class FriendRequestCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayName = isSentRequest
? (request.friendFullName.isNotEmpty
? request.friendFullName
: 'Utilisateur inconnu')
: (request.userFullName.isNotEmpty
? request.userFullName
: 'Utilisateur inconnu');
final initial = displayName.isNotEmpty ? displayName[0].toUpperCase() : '?';
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 28,
radius: 24,
backgroundColor: theme.colorScheme.primaryContainer,
child: Text(
(isSentRequest ? request.friendFullName : request.userFullName).isNotEmpty
? (isSentRequest ? request.friendFullName : request.userFullName)[0].toUpperCase()
: '?',
initial,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: 20,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
// Informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isSentRequest
? (request.friendFullName.isNotEmpty
? request.friendFullName
: 'Utilisateur inconnu')
: (request.userFullName.isNotEmpty
? request.userFullName
: 'Utilisateur inconnu'),
displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
isSentRequest
? 'Demande envoyée'
: 'Demande d\'amitié',
isSentRequest ? 'Demande envoyée' : 'Demande d\'amitié',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
// Boutons d'action
if (isSentRequest)
// Pour les demandes envoyées : seulement bouton Annuler
IconButton(
icon: const Icon(Icons.close),
color: Colors.orange,
onPressed: onReject,
tooltip: 'Annuler la demande',
)
_buildCancelButton(theme)
else
// Pour les demandes reçues : Accepter et Rejeter
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Accepter
IconButton(
icon: const Icon(Icons.check_circle),
color: Colors.green,
onPressed: onAccept,
tooltip: 'Accepter',
),
// Bouton Rejeter
IconButton(
icon: const Icon(Icons.cancel),
color: Colors.red,
onPressed: onReject,
tooltip: 'Rejeter',
),
],
),
_buildActionButtons(theme),
],
),
),
);
}
/// Construit le bouton d'annulation pour les demandes envoyées.
Widget _buildCancelButton(ThemeData theme) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onReject,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.close,
color: Colors.orange,
size: 20,
),
),
),
);
}
/// Construit les boutons d'action pour les demandes reçues.
Widget _buildActionButtons(ThemeData theme) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Accepter
Material(
color: Colors.transparent,
child: InkWell(
onTap: onAccept,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.check_circle,
color: Colors.green,
size: 20,
),
),
),
),
const SizedBox(width: 8),
// Bouton Rejeter
Material(
color: Colors.transparent,
child: InkWell(
onTap: onReject,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.cancel,
color: Colors.red,
size: 20,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../../data/models/friend_suggestion_model.dart';
/// Widget réutilisable pour afficher une suggestion d'ami.
///
/// Ce widget affiche les informations d'un utilisateur suggéré avec
/// un bouton pour envoyer une demande d'ami.
///
/// **Principe DRY :** Ce widget est réutilisable dans n'importe quelle
/// partie de l'application nécessitant d'afficher des suggestions.
class FriendSuggestionCard extends StatelessWidget {
const FriendSuggestionCard({
required this.suggestion,
required this.onAddFriend,
super.key,
});
/// La suggestion d'ami à afficher
final Map<String, dynamic> suggestion;
/// Callback appelé quand l'utilisateur veut ajouter cet ami
final VoidCallback onAddFriend;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Parse la suggestion depuis le JSON
final suggestionModel = FriendSuggestionModel.fromJson(suggestion);
// Obtenir l'initiale pour l'avatar
final initial = suggestionModel.fullName.isNotEmpty
? suggestionModel.fullName[0].toUpperCase()
: '?';
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Avatar avec image de profil ou initiale
CircleAvatar(
radius: 24,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: suggestionModel.profileImageUrl.isNotEmpty &&
suggestionModel.profileImageUrl.startsWith('http')
? NetworkImage(suggestionModel.profileImageUrl)
: null,
child: suggestionModel.profileImageUrl.isEmpty ||
!suggestionModel.profileImageUrl.startsWith('http')
? Text(
initial,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: 18,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: 12),
// Informations de la suggestion
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Nom complet
Text(
suggestionModel.fullName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Raison de la suggestion
Text(
suggestionModel.suggestionReason,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
],
),
),
// Bouton d'ajout
_buildAddButton(theme),
],
),
),
);
}
/// Construit le bouton pour ajouter l'ami suggéré
Widget _buildAddButton(ThemeData theme) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onAddFriend,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.person_add_rounded,
color: theme.colorScheme.primary,
size: 20,
),
),
),
);
}
}

View File

@@ -1,62 +1,127 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class FriendSuggestions extends StatelessWidget {
final Size size;
import '../../data/providers/friends_provider.dart';
import '../../data/models/friend_suggestion_model.dart';
import 'friend_suggestion_card.dart';
const FriendSuggestions({required this.size, Key? key}) : super(key: key);
/// Widget pour afficher une liste de suggestions d'amis.
///
/// Ce widget charge et affiche automatiquement les suggestions d'amis
/// basées sur les amis en commun et d'autres critères.
///
/// **Principe WOU (Write Once Use) :** Ce widget encapsule toute la
/// logique de chargement et d'affichage, utilisable partout.
class FriendSuggestions extends StatefulWidget {
const FriendSuggestions({
this.maxSuggestions = 5,
super.key,
});
/// Nombre maximum de suggestions à afficher
final int maxSuggestions;
@override
State<FriendSuggestions> createState() => _FriendSuggestionsState();
}
class _FriendSuggestionsState extends State<FriendSuggestions> {
@override
void initState() {
super.initState();
// Charger les suggestions au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSuggestions();
});
}
Future<void> _loadSuggestions() async {
final provider = Provider.of<FriendsProvider>(context, listen: false);
try {
await provider.fetchFriendSuggestions(limit: widget.maxSuggestions);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible de charger les suggestions: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _addFriend(BuildContext context, String friendId) async {
final provider = Provider.of<FriendsProvider>(context, listen: false);
try {
await provider.addFriend(friendId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Demande d\'ami envoyée avec succès'),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.green,
),
);
// Recharger les suggestions pour mettre à jour la liste
await _loadSuggestions();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(3, (index) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16.0),
width: size.width,
decoration: BoxDecoration(
color: Colors.grey[850],
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 3),
return Consumer<FriendsProvider>(
builder: (context, provider, child) {
// Afficher un loader pendant le chargement
if (provider.isLoadingSuggestions) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(),
),
);
}
// Afficher un message si aucune suggestion
if (provider.friendSuggestions.isEmpty) {
return const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text(
'Aucune suggestion disponible pour le moment',
style: TextStyle(color: Colors.grey),
),
],
),
child: Row(
children: [
const CircleAvatar(
radius: 30,
backgroundImage: AssetImage('lib/assets/images/friend_placeholder.png'),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Nom d\'utilisateur',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
ElevatedButton(
onPressed: () {
print('Ajouter comme ami');
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Ajouter'),
),
],
),
),
);
}
// Afficher la liste des suggestions
return Column(
mainAxisSize: MainAxisSize.min,
children: provider.friendSuggestions.map((suggestion) {
final suggestionModel = FriendSuggestionModel.fromJson(suggestion);
return FriendSuggestionCard(
suggestion: suggestion,
onAddFriend: () => _addFriend(context, suggestionModel.userId),
);
}).toList(),
);
}),
},
);
}
}

View File

@@ -4,10 +4,10 @@ import 'package:logger/logger.dart';
/// [FriendsAppBar] est une barre d'application personnalisée utilisée dans l'écran des amis.
/// Elle permet d'ajouter et de gérer les amis avec des actions spécifiques.
/// Toutes les actions sont loguées pour une traçabilité complète.
class FriendsAppBar extends StatelessWidget implements PreferredSizeWidget {
final Logger _logger = Logger(); // Logger pour tracer toutes les actions
class FriendsAppBar extends StatelessWidget implements PreferredSizeWidget { // Logger pour tracer toutes les actions
FriendsAppBar({Key? key}) : super(key: key);
FriendsAppBar({super.key});
final Logger _logger = Logger();
@override
Widget build(BuildContext context) {

View File

@@ -8,28 +8,26 @@ import '../../domain/entities/friend.dart';
///
/// Chaque interaction avec le widget sera loguée pour assurer une traçabilité complète.
class FriendsCircle extends StatelessWidget {
/// Constructeur pour [FriendsCircle], prenant en entrée un ami et une fonction de callback.
///
/// @param friend: l'ami à afficher, comprenant les informations nécessaires (nom, prénom, imageUrl).
/// @param onTap: la fonction qui sera exécutée lorsque l'utilisateur clique sur l'avatar.
FriendsCircle({super.key,
required this.friend, // L'ami à afficher (doit inclure friendId, name, imageUrl)., required this.onTap, // Action à exécuter lors du clic., super.key,
});
final Friend friend; // L'entité Friend à afficher (contenant l'ID, le prénom, le nom, et l'URL de l'image).
final VoidCallback onTap; // La fonction callback qui sera exécutée lors du clic sur l'avatar.
// Initialisation du logger pour tracer les actions dans le terminal.
final Logger _logger = Logger();
/// Constructeur pour [FriendsCircle], prenant en entrée un ami et une fonction de callback.
///
/// @param friend: l'ami à afficher, comprenant les informations nécessaires (nom, prénom, imageUrl).
/// @param onTap: la fonction qui sera exécutée lorsque l'utilisateur clique sur l'avatar.
FriendsCircle({
Key? key,
required this.friend, // L'ami à afficher (doit inclure friendId, name, imageUrl).
required this.onTap, // Action à exécuter lors du clic.
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 1. Récupère et assemble les prénoms et noms de l'ami, ou définit "Ami inconnu" si ces valeurs sont vides.
String displayName = [friend.friendFirstName, friend.friendLastName]
.where((namePart) => namePart != null && namePart.isNotEmpty) // Exclut les parties nulles ou vides.
.join(" ") // Joint les parties pour obtenir un nom complet.
.where((namePart) => namePart.isNotEmpty) // Exclut les parties nulles ou vides.
.join(' ') // Joint les parties pour obtenir un nom complet.
.trim(); // Supprime les espaces superflus.
if (displayName.isEmpty) {

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'modern_empty_state.dart';
/// Widget affichant l'état vide de la liste des amis.
class FriendsEmptyState extends StatelessWidget {
const FriendsEmptyState({
required this.theme,
this.onAddFriend,
super.key,
});
final ThemeData theme;
final VoidCallback? onAddFriend;
@override
Widget build(BuildContext context) {
return ModernEmptyState(
illustration: EmptyStateIllustration.friends,
title: 'Aucun ami trouvé',
description: 'Commencez à ajouter des amis pour voir leurs événements et partager des moments ensemble',
actionLabel: onAddFriend != null ? 'Ajouter un ami' : null,
onAction: onAddFriend,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'shimmer_loading.dart';
/// Widget affichant l'état de chargement de la liste des amis avec skeleton loaders.
class FriendsLoadingState extends StatelessWidget {
const FriendsLoadingState({required this.theme, super.key});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return SkeletonGrid(
itemCount: 6,
skeletonWidget: const FriendCardSkeleton(),
crossAxisCount: 3,
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../data/providers/friends_provider.dart';
import 'cards/friend_card.dart';
import 'friends_empty_state.dart';
import 'friends_loading_state.dart';
import 'search_friends.dart';
/// Onglet affichant la liste des amis avec recherche et pagination.
class FriendsTab extends StatelessWidget {
const FriendsTab({
required this.userId,
required this.scrollController,
required this.onRefresh,
super.key,
});
final String userId;
final ScrollController scrollController;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
_buildSearchBar(),
Expanded(
child: Consumer<FriendsProvider>(
builder: (context, provider, child) {
if (provider.isLoading && provider.friendsList.isEmpty) {
return FriendsLoadingState(theme: theme);
}
if (provider.friendsList.isEmpty) {
return RefreshIndicator(
onRefresh: () async => onRefresh(),
color: theme.colorScheme.primary,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 300,
child: FriendsEmptyState(theme: theme),
),
),
);
}
return _buildFriendsList(theme, provider);
},
),
),
],
);
}
/// Construit la barre de recherche.
Widget _buildSearchBar() {
return const Padding(
padding: EdgeInsets.all(16),
child: SearchFriends(),
);
}
/// Construit la liste des amis.
Widget _buildFriendsList(ThemeData theme, FriendsProvider provider) {
return RefreshIndicator(
onRefresh: () async => onRefresh(),
color: theme.colorScheme.primary,
child: GridView.builder(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.68,
),
itemCount: provider.friendsList.length,
itemBuilder: (context, index) {
final friend = provider.friendsList[index];
return FriendCard(friend: friend);
},
),
);
}
}

View File

@@ -0,0 +1,6 @@
/// [FriendshipStatus] représente les différents statuts possibles d'une amitié.
enum FriendshipStatus {
PENDING, // En attente
ACCEPTED, // Acceptée
REJECTED, // Rejetée
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'friendship_status.dart';
/// [FriendshipStatusBadge] est un widget qui affiche le statut de l'amitié sous forme de badge.
/// Il peut afficher des statuts comme "En attente", "Acceptée", "Rejetée".
class FriendshipStatusBadge extends StatelessWidget { // Le statut de l'amitié (PENDING, ACCEPTED, REJECTED)
const FriendshipStatusBadge({
required this.status, super.key,
});
final FriendshipStatus status;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getStatusColor(status).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getStatusColor(status),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getStatusIcon(status),
color: _getStatusColor(status),
size: 16,
),
const SizedBox(width: 8),
Text(
_getStatusText(status),
style: TextStyle(
color: _getStatusColor(status),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
/// Retourne la couleur associée au statut.
Color _getStatusColor(FriendshipStatus status) {
switch (status) {
case FriendshipStatus.PENDING:
return Colors.orange;
case FriendshipStatus.ACCEPTED:
return Colors.green;
case FriendshipStatus.REJECTED:
return Colors.red;
default:
return Colors.grey;
}
}
/// Retourne l'icône associée au statut.
IconData _getStatusIcon(FriendshipStatus status) {
switch (status) {
case FriendshipStatus.PENDING:
return Icons.hourglass_empty;
case FriendshipStatus.ACCEPTED:
return Icons.check_circle;
case FriendshipStatus.REJECTED:
return Icons.cancel;
default:
return Icons.help_outline;
}
}
/// Retourne le texte associé au statut.
String _getStatusText(FriendshipStatus status) {
switch (status) {
case FriendshipStatus.PENDING:
return 'En attente';
case FriendshipStatus.ACCEPTED:
return 'Acceptée';
case FriendshipStatus.REJECTED:
return 'Rejetée';
default:
return 'Inconnu';
}
}
}

View File

@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/design_system.dart';
/// Widget pour afficher une image en plein écran avec Hero animation.
///
/// Ce widget affiche une image en plein écran avec des fonctionnalités
/// de zoom, rotation, et fermeture par glissement.
///
/// **Fonctionnalités:**
/// - Hero animation pour transition fluide
/// - Pinch to zoom et rotation
/// - Swipe down pour fermer
/// - Contrôles en overlay (fermer, télécharger)
/// - Mode immersif (masque la barre de statut)
class FullscreenImageViewer extends StatefulWidget {
const FullscreenImageViewer({
required this.imageUrl,
required this.heroTag,
this.title,
super.key,
});
/// URL de l'image à afficher
final String imageUrl;
/// Tag Hero pour l'animation de transition
final String heroTag;
/// Titre optionnel affiché en haut
final String? title;
@override
State<FullscreenImageViewer> createState() => _FullscreenImageViewerState();
}
class _FullscreenImageViewerState extends State<FullscreenImageViewer>
with SingleTickerProviderStateMixin {
bool _showControls = true;
final TransformationController _transformationController =
TransformationController();
@override
void initState() {
super.initState();
// Mode immersif
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// Restaurer la barre de statut
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
_transformationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggleControls,
child: Stack(
children: [
// Image avec zoom et rotation
Center(
child: Hero(
tag: widget.heroTag,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
widget.imageUrl,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
color: theme.colorScheme.primary,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 64,
),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.white70,
),
),
],
),
);
},
),
),
),
),
// Contrôles en overlay
if (_showControls) ...[
// Header avec titre et bouton fermer
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.6),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
tooltip: 'Fermer',
),
if (widget.title != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
widget.title!,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
),
),
),
// Footer avec actions
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.6),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, color: Colors.white),
onPressed: _zoomIn,
tooltip: 'Zoom +',
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.zoom_out, color: Colors.white),
onPressed: _zoomOut,
tooltip: 'Zoom -',
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white),
onPressed: _resetZoom,
tooltip: 'Réinitialiser',
),
],
),
),
),
),
),
],
],
),
),
);
}
/// Bascule l'affichage des contrôles
void _toggleControls() {
setState(() {
_showControls = !_showControls;
});
}
/// Zoom avant
void _zoomIn() {
final currentScale = _transformationController.value.getMaxScaleOnAxis();
final newScale = (currentScale * 1.5).clamp(0.5, 4.0);
_transformationController.value = Matrix4.identity()..scale(newScale);
}
/// Zoom arrière
void _zoomOut() {
final currentScale = _transformationController.value.getMaxScaleOnAxis();
final newScale = (currentScale / 1.5).clamp(0.5, 4.0);
_transformationController.value = Matrix4.identity()..scale(newScale);
}
/// Réinitialise le zoom
void _resetZoom() {
_transformationController.value = Matrix4.identity();
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
class GroupList extends StatelessWidget {
final Size size;
import '../../core/utils/app_logger.dart';
const GroupList({required this.size, Key? key}) : super(key: key);
class GroupList extends StatelessWidget {
const GroupList({required this.size, super.key});
final Size size;
@override
Widget build(BuildContext context) {
@@ -11,7 +13,7 @@ class GroupList extends StatelessWidget {
children: List.generate(3, (index) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
width: size.width,
decoration: BoxDecoration(
color: Colors.grey[850],
@@ -44,7 +46,7 @@ class GroupList extends StatelessWidget {
),
ElevatedButton(
onPressed: () {
print('Rejoindre le groupe');
AppLogger.i('Rejoindre le groupe', tag: 'GroupList');
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,

View File

@@ -1,34 +1,158 @@
import 'package:flutter/material.dart';
/// Champ de sélection d'image avec aperçu et support du thème.
///
/// Ce widget fournit un champ de sélection d'image cohérent avec le design system,
/// avec support de l'aperçu de l'image sélectionnée.
///
/// **Usage:**
/// ```dart
/// ImagePickerField(
/// label: 'Image de l\'événement',
/// imagePath: selectedImagePath,
/// onImagePicked: () {
/// // Ouvrir le sélecteur d'image
/// },
/// )
/// ```
class ImagePickerField extends StatelessWidget {
/// Crée un nouveau [ImagePickerField].
///
/// [onImagePicked] La fonction appelée pour ouvrir le sélecteur d'image
/// [imagePath] Le chemin de l'image sélectionnée (optionnel)
/// [label] Le texte du label (par défaut: 'Sélectionnez une image')
/// [imageUrl] L'URL de l'image (optionnel, prioritaire sur imagePath)
const ImagePickerField({
required this.onImagePicked,
super.key,
this.imagePath,
this.imageUrl,
this.label = 'Sélectionnez une image',
});
/// Le chemin de l'image sélectionnée
final String? imagePath;
/// L'URL de l'image (prioritaire sur imagePath)
final String? imageUrl;
/// La fonction appelée pour ouvrir le sélecteur d'image
final VoidCallback onImagePicked;
const ImagePickerField({Key? key, this.imagePath, required this.onImagePicked}) : super(key: key);
/// Le texte du label
final String label;
/// Retourne true si une image est sélectionnée
bool get hasImage => (imageUrl != null && imageUrl!.isNotEmpty) ||
(imagePath != null && imagePath!.isNotEmpty);
@override
Widget build(BuildContext context) {
return GestureDetector(
final theme = Theme.of(context);
return InkWell(
onTap: onImagePicked,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(10.0),
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasImage
? theme.colorScheme.primary
: theme.colorScheme.outline.withOpacity(0.5),
width: hasImage ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
imagePath == null
? 'Sélectionnez une image'
: 'Image sélectionnée: $imagePath',
style: const TextStyle(color: Colors.white70),
// Aperçu de l'image si disponible
if (hasImage)
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
image: imageUrl != null && imageUrl!.isNotEmpty
? DecorationImage(
image: NetworkImage(imageUrl!),
fit: BoxFit.cover,
onError: (exception, stackTrace) {
// En cas d'erreur de chargement, afficher une icône
},
)
: imagePath != null && imagePath!.isNotEmpty
? DecorationImage(
image: AssetImage(imagePath!),
fit: BoxFit.cover,
)
: null,
),
child: imageUrl == null &&
imagePath == null
? Icon(
Icons.image,
color: theme.colorScheme.primary,
size: 30,
)
: null,
)
else
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.add_photo_alternate,
color: theme.colorScheme.primary,
size: 30,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasImage)
Text(
'Image sélectionnée',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 4),
Text(
hasImage
? (imageUrl ?? imagePath ?? 'Image')
: label,
style: theme.textTheme.bodyLarge?.copyWith(
color: hasImage
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: hasImage ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Icon(
hasImage ? Icons.edit : Icons.photo_camera,
color: hasImage
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 24,
),
const Icon(Icons.image, color: Colors.white70),
],
),
),
);
}
}

View File

@@ -8,9 +8,9 @@ import 'package:image_picker/image_picker.dart';
/// Arguments :
/// - `onImagePicked`: Un callback qui renvoie le fichier image sélectionné (ou null si aucune image n'est choisie).
class ImagePreviewPicker extends StatefulWidget {
final void Function(File?) onImagePicked;
const ImagePreviewPicker({Key? key, required this.onImagePicked}) : super(key: key);
const ImagePreviewPicker({required this.onImagePicked, super.key});
final void Function(File?) onImagePicked;
@override
_ImagePreviewPickerState createState() => _ImagePreviewPickerState();
@@ -79,20 +79,20 @@ class _ImagePreviewPickerState extends State<ImagePreviewPicker> {
const SizedBox(height: 8),
AnimatedContainer(
duration: const Duration(milliseconds: 300), // Animation douce lors du changement d'image
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.1), // Fond légèrement opaque
borderRadius: BorderRadius.circular(12.0), // Bordures arrondies
borderRadius: BorderRadius.circular(12), // Bordures arrondies
border: Border.all(
color: _selectedImageFile != null ? Colors.blue : Colors.blueGrey,
width: 2.0, // Bordure visible autour de l'image
width: 2, // Bordure visible autour de l'image
),
),
child: AspectRatio(
aspectRatio: 16 / 9, // Maintient l'aspect ratio de l'image
child: _selectedImageFile != null
? ClipRRect(
borderRadius: BorderRadius.circular(10.0),
borderRadius: BorderRadius.circular(10),
child: Image.file(
_selectedImageFile!,
fit: BoxFit.cover,

View File

@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
import '../../domain/entities/notification.dart' as domain;
import 'animated_widgets.dart';
/// Notification in-app affichée en overlay au-dessus du contenu.
///
/// S'affiche en haut de l'écran avec une animation de slide down
/// et disparaît automatiquement après quelques secondes.
///
/// **Usage:**
/// ```dart
/// InAppNotification.show(
/// context: context,
/// notification: myNotification,
/// onTap: () {
/// // Naviguer vers la notification
/// },
/// );
/// ```
class InAppNotification {
/// Affiche une notification in-app en overlay.
static void show({
required BuildContext context,
required domain.Notification notification,
Duration duration = const Duration(seconds: 4),
VoidCallback? onTap,
VoidCallback? onDismiss,
}) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => _InAppNotificationWidget(
notification: notification,
onTap: () {
overlayEntry.remove();
onTap?.call();
},
onDismiss: () {
overlayEntry.remove();
onDismiss?.call();
},
),
);
overlay.insert(overlayEntry);
// Supprime automatiquement après la durée spécifiée
Future.delayed(duration, () {
if (overlayEntry.mounted) {
overlayEntry.remove();
onDismiss?.call();
}
});
}
}
class _InAppNotificationWidget extends StatefulWidget {
const _InAppNotificationWidget({
required this.notification,
required this.onTap,
required this.onDismiss,
});
final domain.Notification notification;
final VoidCallback onTap;
final VoidCallback onDismiss;
@override
State<_InAppNotificationWidget> createState() =>
_InAppNotificationWidgetState();
}
class _InAppNotificationWidgetState extends State<_InAppNotificationWidget>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.durationNormal,
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeIn,
),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Positioned(
top: 0,
left: 0,
right: 0,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.paddingMedium),
child: Dismissible(
key: ValueKey(widget.notification.id),
direction: DismissDirection.up,
onDismissed: (direction) {
widget.onDismiss();
},
child: AnimatedScaleButton(
onTap: widget.onTap,
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(DesignSystem.radiusMedium),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(DesignSystem.radiusMedium),
child: Padding(
padding: const EdgeInsets.all(DesignSystem.paddingMedium),
child: Row(
children: [
// Icône selon le type
Container(
padding: const EdgeInsets.all(DesignSystem.paddingSmall),
decoration: BoxDecoration(
color: _getNotificationColor(widget.notification.type)
.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusSmall),
),
child: Icon(
_getNotificationIcon(widget.notification.type),
color: _getNotificationColor(widget.notification.type),
size: 24,
),
),
const SizedBox(width: DesignSystem.paddingMedium),
// Contenu
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.notification.title,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
widget.notification.message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Bouton fermer
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
),
),
),
),
),
),
),
),
),
),
);
}
Color _getNotificationColor(domain.NotificationType type) {
switch (type) {
case domain.NotificationType.event:
return Colors.blue;
case domain.NotificationType.friend:
return Colors.green;
case domain.NotificationType.reminder:
return Colors.orange;
case domain.NotificationType.other:
return Colors.grey;
}
}
IconData _getNotificationIcon(domain.NotificationType type) {
switch (type) {
case domain.NotificationType.event:
return Icons.event;
case domain.NotificationType.friend:
return Icons.person_add;
case domain.NotificationType.reminder:
return Icons.alarm;
case domain.NotificationType.other:
return Icons.notifications;
}
}
}

View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
import '../../core/utils/app_logger.dart';
import '../../core/utils/date_formatter.dart';
import '../../domain/entities/chat_message.dart';
/// Widget pour afficher une bulle de message dans le chat.
///
/// **Fonctionnalités :**
/// - Design moderne inspiré de WhatsApp/Telegram 2025
/// - Groupement de messages (via `showAvatar`)
/// - Statuts de message : ✓ envoyé, ✓✓ délivré, ✓✓ (bleu) lu
/// - Support des attachments (image, vidéo, audio, fichier)
/// - Timestamp intelligent avec formatage contextuel
class MessageBubble extends StatelessWidget {
const MessageBubble({
required this.message,
required this.isCurrentUser,
this.showAvatar = true,
this.isFirstInGroup = false,
this.isLastInGroup = false,
super.key,
});
final ChatMessage message;
final bool isCurrentUser;
/// Afficher l'avatar (seulement sur le dernier message d'un groupe)
final bool showAvatar;
/// Premier message d'un groupe (même expéditeur)
final bool isFirstInGroup;
/// Dernier message d'un groupe (même expéditeur)
final bool isLastInGroup;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Padding selon le groupement
final bottomPadding = isLastInGroup ? DesignSystem.spacingMd : 2.0;
final leftPadding = isCurrentUser ? 64.0 : (showAvatar ? 8.0 : 48.0);
final rightPadding = isCurrentUser ? 8.0 : 64.0;
return Padding(
padding: EdgeInsets.only(
left: leftPadding,
right: rightPadding,
bottom: bottomPadding,
),
child: Row(
mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Avatar pour les messages reçus (seulement sur le dernier message du groupe)
if (!isCurrentUser && showAvatar) ...[
_buildAvatar(),
const SizedBox(width: DesignSystem.spacingSm),
],
// Espace invisible pour alignement quand pas d'avatar
if (!isCurrentUser && !showAvatar)
const SizedBox(width: 40),
// Bulle de message
Flexible(
child: _buildMessageContainer(theme),
),
],
),
);
}
/// Construit l'avatar de l'expéditeur.
Widget _buildAvatar() {
final initial = message.senderFirstName.isNotEmpty
? message.senderFirstName[0].toUpperCase()
: '?';
return CircleAvatar(
radius: 18,
backgroundImage: message.senderProfileImageUrl != null &&
message.senderProfileImageUrl!.isNotEmpty &&
message.senderProfileImageUrl!.startsWith('http')
? NetworkImage(message.senderProfileImageUrl!)
: null,
child: message.senderProfileImageUrl == null ||
message.senderProfileImageUrl!.isEmpty ||
!message.senderProfileImageUrl!.startsWith('http')
? Text(initial, style: const TextStyle(fontSize: 16))
: null,
);
}
/// Construit le conteneur de la bulle de message.
Widget _buildMessageContainer(ThemeData theme) {
final bgColor = isCurrentUser
? theme.colorScheme.primary
: theme.brightness == Brightness.dark
? Colors.grey[800]!
: Colors.grey[200]!;
final textColor = isCurrentUser
? Colors.white
: theme.brightness == Brightness.dark
? Colors.white
: Colors.black87;
return Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: isCurrentUser
? const Radius.circular(18)
: const Radius.circular(4),
bottomRight: isCurrentUser
? const Radius.circular(4)
: const Radius.circular(18),
),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom de l'expéditeur (seulement pour messages reçus ET premier du groupe)
if (!isCurrentUser && isFirstInGroup && message.senderFullName.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
message.senderFullName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
// Pièce jointe ou contenu
if (message.hasAttachment)
_buildAttachment(textColor, theme)
else
Text(
message.content,
style: TextStyle(fontSize: 15, color: textColor),
),
const SizedBox(height: 4),
// Timestamp et statuts
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
ChatDateFormatter.formatMessageTimestamp(message.timestamp),
style: TextStyle(
fontSize: 11,
color: isCurrentUser
? Colors.white.withOpacity(0.7)
: textColor.withOpacity(0.6),
),
),
if (isCurrentUser) ...[
const SizedBox(width: 4),
_buildMessageStatus(),
],
],
),
],
),
);
}
/// Construit l'indicateur de statut du message (envoyé/délivré/lu).
Widget _buildMessageStatus() {
// DEBUG: Afficher les statuts dans la console
if (isCurrentUser) {
AppLogger.d('Message ${message.id.substring(0, 8)}: isRead=${message.isRead}, isDelivered=${message.isDelivered}', tag: 'MessageBubble');
}
if (message.isRead) {
// Lu : Double check bleu (très visible)
return const Icon(
Icons.done_all,
size: 18,
color: Color(0xFF0096FF), // Bleu vif pour "lu"
);
} else if (message.isDelivered) {
// Délivré : Double check blanc/gris clair (bien visible)
return Icon(
Icons.done_all,
size: 18,
color: Colors.white.withOpacity(0.95),
);
} else {
// Envoyé : Simple check blanc/gris clair
return Icon(
Icons.done,
size: 18,
color: Colors.white.withOpacity(0.95),
);
}
}
/// Construit l'affichage d'une pièce jointe.
Widget _buildAttachment(Color textColor, ThemeData theme) {
if (message.attachmentType == AttachmentType.image) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
message.attachmentUrl!,
width: 250,
height: 250,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 250,
height: 250,
color: Colors.grey[300],
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image,
size: 48,
color: Colors.grey[600],
),
const SizedBox(height: 8),
Text(
'Image non disponible',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
],
),
);
},
),
),
if (message.content.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
message.content,
style: TextStyle(fontSize: 15, color: textColor),
),
],
],
);
}
// Autres types d'attachments (vidéo, audio, fichier)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_getAttachmentIcon(), color: textColor, size: 28),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getAttachmentTypeName(),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (message.content.isNotEmpty)
Text(
message.content,
style: TextStyle(
color: textColor.withOpacity(0.8),
fontSize: 13,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
/// Retourne l'icône correspondant au type d'attachment.
IconData _getAttachmentIcon() {
switch (message.attachmentType) {
case AttachmentType.video:
return Icons.videocam_rounded;
case AttachmentType.audio:
return Icons.audiotrack_rounded;
case AttachmentType.file:
return Icons.insert_drive_file_rounded;
default:
return Icons.attach_file_rounded;
}
}
/// Retourne le nom d'affichage du type d'attachment.
String _getAttachmentTypeName() {
switch (message.attachmentType) {
case AttachmentType.video:
return 'Vidéo';
case AttachmentType.audio:
return 'Audio';
case AttachmentType.file:
return 'Fichier';
default:
return 'Pièce jointe';
}
}
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
import 'animated_widgets.dart';
/// Widget moderne pour afficher un état vide avec illustration et animation.
///
/// Ce widget affiche un état vide élégant avec :
/// - Une illustration personnalisée (icône ou widget custom)
/// - Un titre et une description
/// - Un bouton d'action optionnel
/// - Des animations fluides
///
/// **Usage:**
/// ```dart
/// ModernEmptyState(
/// illustration: EmptyStateIllustration.friends,
/// title: 'Aucun ami trouvé',
/// description: 'Commencez à ajouter des amis',
/// actionLabel: 'Ajouter un ami',
/// onAction: () => _addFriend(),
/// )
/// ```
class ModernEmptyState extends StatelessWidget {
const ModernEmptyState({
required this.illustration,
required this.title,
this.description,
this.actionLabel,
this.onAction,
super.key,
});
/// Type d'illustration à afficher
final EmptyStateIllustration illustration;
/// Titre principal
final String title;
/// Description optionnelle
final String? description;
/// Label du bouton d'action
final String? actionLabel;
/// Callback du bouton d'action
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: SingleChildScrollView(
padding: DesignSystem.paddingAll(DesignSystem.spacingXl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Illustration animée
FadeInWidget(
duration: DesignSystem.durationMedium,
child: PulseAnimation(
duration: const Duration(seconds: 2),
child: _buildIllustration(theme),
),
),
const SizedBox(height: 32),
// Titre
FadeInWidget(
delay: const Duration(milliseconds: 200),
duration: DesignSystem.durationMedium,
child: Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
),
// Description
if (description != null) ...[
const SizedBox(height: 12),
FadeInWidget(
delay: const Duration(milliseconds: 300),
duration: DesignSystem.durationMedium,
child: Text(
description!,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
),
],
// Bouton d'action
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 32),
FadeInWidget(
delay: const Duration(milliseconds: 400),
duration: DesignSystem.durationMedium,
child: AnimatedScaleButton(
onTap: onAction!,
child: ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(actionLabel!),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: DesignSystem.borderRadiusMd,
),
),
),
),
),
],
],
),
),
);
}
/// Construit l'illustration selon le type
Widget _buildIllustration(ThemeData theme) {
final config = _getIllustrationConfig(illustration);
return Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
config.color.withOpacity(0.1),
config.color.withOpacity(0.02),
],
),
),
child: Center(
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: config.color.withOpacity(0.15),
),
child: Icon(
config.icon,
size: 64,
color: config.color,
),
),
),
);
}
/// Retourne la configuration de l'illustration
_IllustrationConfig _getIllustrationConfig(EmptyStateIllustration type) {
switch (type) {
case EmptyStateIllustration.friends:
return _IllustrationConfig(
icon: Icons.people_outline,
color: Colors.blue,
);
case EmptyStateIllustration.requests:
return _IllustrationConfig(
icon: Icons.person_add_outlined,
color: Colors.orange,
);
case EmptyStateIllustration.events:
return _IllustrationConfig(
icon: Icons.event_note_outlined,
color: Colors.purple,
);
case EmptyStateIllustration.notifications:
return _IllustrationConfig(
icon: Icons.notifications_none_outlined,
color: Colors.teal,
);
case EmptyStateIllustration.messages:
return _IllustrationConfig(
icon: Icons.chat_bubble_outline,
color: Colors.green,
);
case EmptyStateIllustration.search:
return _IllustrationConfig(
icon: Icons.search_off_outlined,
color: Colors.grey,
);
case EmptyStateIllustration.social:
return _IllustrationConfig(
icon: Icons.forum_outlined,
color: Colors.pink,
);
case EmptyStateIllustration.reservations:
return _IllustrationConfig(
icon: Icons.bookmark_border_outlined,
color: Colors.amber,
);
case EmptyStateIllustration.establishments:
return _IllustrationConfig(
icon: Icons.store_outlined,
color: Colors.deepOrange,
);
}
}
}
/// Types d'illustrations disponibles pour les états vides
enum EmptyStateIllustration {
friends,
requests,
events,
notifications,
messages,
search,
social,
reservations,
establishments,
}
/// Configuration d'une illustration
class _IllustrationConfig {
const _IllustrationConfig({
required this.icon,
required this.color,
});
final IconData icon;
final Color color;
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/design_system.dart';
/// Widget de badge affichant le nombre de notifications non lues.
///
/// Utilisé dans l'AppBar, BottomNavigationBar, ou partout où un compteur
/// de notifications est nécessaire.
///
/// **Usage:**
/// ```dart
/// NotificationBadge(
/// count: 5,
/// child: Icon(Icons.notifications),
/// )
/// ```
class NotificationBadge extends StatelessWidget {
const NotificationBadge({
required this.count,
required this.child,
this.showZero = false,
this.maxCount = 99,
super.key,
});
/// Nombre de notifications non lues
final int count;
/// Widget enfant sur lequel afficher le badge
final Widget child;
/// Afficher le badge même si count == 0
final bool showZero;
/// Nombre maximum à afficher (au-delà, affiche "99+")
final int maxCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final shouldShow = count > 0 || showZero;
return Stack(
clipBehavior: Clip.none,
children: [
child,
if (shouldShow)
Positioned(
right: -6,
top: -6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.scaffoldBackgroundColor,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Center(
child: Text(
count > maxCount ? '$maxCount+' : count.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
),
),
),
),
],
);
}
}
/// Widget de point rouge simple pour indiquer des notifications non lues.
///
/// Plus discret que [NotificationBadge], affiche juste un point rouge
/// sans compter le nombre.
///
/// **Usage:**
/// ```dart
/// NotificationDot(
/// show: hasUnreadNotifications,
/// child: Icon(Icons.notifications),
/// )
/// ```
class NotificationDot extends StatelessWidget {
const NotificationDot({
required this.show,
required this.child,
super.key,
});
/// Afficher ou non le point rouge
final bool show;
/// Widget enfant sur lequel afficher le dot
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
clipBehavior: Clip.none,
children: [
child,
if (show)
Positioned(
right: -2,
top: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(
color: theme.scaffoldBackgroundColor,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
),
),
],
);
}
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class PopularActivityList extends StatelessWidget {
final Size size;
const PopularActivityList({required this.size, Key? key}) : super(key: key);
const PopularActivityList({required this.size, super.key});
final Size size;
@override
Widget build(BuildContext context) {
@@ -26,7 +26,7 @@ class PopularActivityList extends StatelessWidget {
),
),
child: const Padding(
padding: EdgeInsets.all(12.0),
padding: EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -8,17 +8,16 @@ import '../../../../domain/entities/user.dart';
/// avec un gradient élégant, des animations, et un bouton de déconnexion stylisé.
/// Entièrement logué pour une traçabilité complète.
class ProfileHeader extends StatelessWidget {
final User user;
const ProfileHeader({Key? key, required this.user}) : super(key: key);
const ProfileHeader({required this.user, super.key});
final User user;
@override
Widget build(BuildContext context) {
debugPrint("[LOG] Initialisation de ProfileHeader pour l'utilisateur : ${user.userFirstName} ${user.userLastName}");
return SliverAppBar(
expandedHeight: 250.0,
floating: false,
expandedHeight: 250,
pinned: true,
elevation: 0,
backgroundColor: AppColors.darkPrimary,
@@ -30,21 +29,21 @@ class ProfileHeader extends StatelessWidget {
/// Construit un FlexibleSpaceBar avec un gradient et des animations.
/// Affiche le nom de l'utilisateur et l'image de profil avec un effet visuel enrichi.
Widget _buildFlexibleSpaceBar(User user) {
debugPrint("[LOG] Construction de FlexibleSpaceBar avec nom et image de profil.");
debugPrint('[LOG] Construction de FlexibleSpaceBar avec nom et image de profil.');
return FlexibleSpaceBar(
centerTitle: true,
title: Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(12.0),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Profil de ${user.userFirstName}',
style: TextStyle(
color: AppColors.accentColor,
fontSize: 18.0,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
@@ -96,7 +95,7 @@ class ProfileHeader extends StatelessWidget {
icon: const Icon(Icons.logout, color: Colors.white),
splashRadius: 20,
onPressed: () {
debugPrint("[LOG] Clic sur le bouton de déconnexion.");
debugPrint('[LOG] Clic sur le bouton de déconnexion.');
_showLogoutConfirmationDialog(context);
},
tooltip: 'Déconnexion',
@@ -109,7 +108,7 @@ class ProfileHeader extends StatelessWidget {
showDialog(
context: context,
builder: (BuildContext context) {
debugPrint("[LOG] Affichage de la boîte de dialogue de confirmation de déconnexion.");
debugPrint('[LOG] Affichage de la boîte de dialogue de confirmation de déconnexion.');
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
@@ -129,7 +128,7 @@ class ProfileHeader extends StatelessWidget {
);
},
).then((_) {
debugPrint("[LOG] Fermeture de la boîte de dialogue de déconnexion.");
debugPrint('[LOG] Fermeture de la boîte de dialogue de déconnexion.');
});
}
@@ -155,7 +154,7 @@ class ProfileHeader extends StatelessWidget {
// Réinitialisation des informations de l'utilisateur
Provider.of<UserProvider>(context, listen: false).resetUser();
debugPrint("[LOG] Informations utilisateur réinitialisées dans UserProvider.");
debugPrint('[LOG] Informations utilisateur réinitialisées dans UserProvider.');
Navigator.of(context).pop();
Navigator.of(context).pushReplacementNamed('/'); // Redirection vers l'écran de connexion

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
class QuickActionButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final double fontSize; // Ajout d'un paramètre pour personnaliser la taille du texte
class QuickActionButton extends StatelessWidget { // Ajout d'un paramètre pour personnaliser la taille du texte
const QuickActionButton({
required this.label,
required this.icon,
required this.color,
this.fontSize = 14, // Valeur par défaut
Key? key,
}) : super(key: key);
super.key,
});
final String label;
final IconData icon;
final Color color;
final double fontSize;
@override
Widget build(BuildContext context) {

View File

@@ -0,0 +1,244 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../data/services/realtime_notification_service.dart';
/// Widget qui écoute les streams de notifications et affiche des toasts.
///
/// Ce widget doit wrapper l'application pour avoir accès au BuildContext
/// et pouvoir afficher les notifications visuelles (SnackBar, Dialog, etc.).
///
/// **Usage :**
/// ```dart
/// MaterialApp(
/// home: RealtimeNotificationHandler(
/// realtimeService: _realtimeService,
/// child: HomeScreen(),
/// ),
/// );
/// ```
class RealtimeNotificationHandler extends StatefulWidget {
const RealtimeNotificationHandler({
required this.child,
required this.realtimeService,
super.key,
});
final Widget child;
final RealtimeNotificationService realtimeService;
@override
State<RealtimeNotificationHandler> createState() => _RealtimeNotificationHandlerState();
}
class _RealtimeNotificationHandlerState extends State<RealtimeNotificationHandler> {
StreamSubscription<FriendRequestNotification>? _friendRequestSub;
StreamSubscription<SystemNotification>? _systemNotificationSub;
StreamSubscription<MessageAlert>? _messageAlertSub;
@override
void initState() {
super.initState();
_setupListeners();
}
void _setupListeners() {
// Écouter les demandes d'amitié
_friendRequestSub = widget.realtimeService.friendRequestStream.listen((notification) {
_showToast(notification.toDisplayMessage(), type: _getNotificationType(notification.type));
});
// Écouter les notifications système
_systemNotificationSub = widget.realtimeService.systemNotificationStream.listen((notification) {
_showSystemNotification(notification);
});
// Écouter les alertes de messages
_messageAlertSub = widget.realtimeService.messageAlertStream.listen((alert) {
_showToast('Nouveau message de ${alert.senderName}', type: NotificationType.info);
});
}
/// Détermine le type de notification pour le style du toast.
NotificationType _getNotificationType(String type) {
switch (type) {
case 'accepted':
return NotificationType.success;
case 'rejected':
return NotificationType.warning;
case 'received':
default:
return NotificationType.info;
}
}
/// Affiche un toast (SnackBar) avec le message.
void _showToast(String message, {NotificationType type = NotificationType.info}) {
if (!mounted) return;
final color = _getColorForType(type);
final icon = _getIconForType(type);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(color: Colors.white),
),
),
],
),
backgroundColor: color,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
/// Affiche une notification système plus élaborée.
void _showSystemNotification(SystemNotification notification) {
if (!mounted) return;
final color = _getColorForSystemNotificationType(notification.type);
final icon = _getIconForSystemNotificationType(notification.type);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
notification.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
if (notification.message.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
notification.message,
style: const TextStyle(color: Colors.white70, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
backgroundColor: color,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
/// Retourne la couleur appropriée selon le type de notification.
Color _getColorForType(NotificationType type) {
switch (type) {
case NotificationType.success:
return Colors.green.shade600;
case NotificationType.warning:
return Colors.orange.shade600;
case NotificationType.error:
return Colors.red.shade600;
case NotificationType.info:
default:
return Colors.blue.shade600;
}
}
/// Retourne l'icône appropriée selon le type de notification.
IconData _getIconForType(NotificationType type) {
switch (type) {
case NotificationType.success:
return Icons.check_circle;
case NotificationType.warning:
return Icons.warning;
case NotificationType.error:
return Icons.error;
case NotificationType.info:
default:
return Icons.info;
}
}
/// Retourne la couleur appropriée selon le type de notification système.
Color _getColorForSystemNotificationType(String type) {
switch (type.toLowerCase()) {
case 'event':
return Colors.blue.shade600;
case 'friend':
return Colors.green.shade600;
case 'reminder':
return Colors.orange.shade600;
default:
return Colors.grey.shade700;
}
}
/// Retourne l'icône appropriée selon le type de notification système.
IconData _getIconForSystemNotificationType(String type) {
switch (type.toLowerCase()) {
case 'event':
return Icons.event;
case 'friend':
return Icons.people;
case 'reminder':
return Icons.notifications;
default:
return Icons.info;
}
}
@override
void dispose() {
_friendRequestSub?.cancel();
_systemNotificationSub?.cancel();
_messageAlertSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Enum pour les types de notifications toast.
enum NotificationType {
success,
warning,
error,
info,
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
class RecommendedEventList extends StatelessWidget {
final Size size;
const RecommendedEventList({required this.size, Key? key}) : super(key: key);
const RecommendedEventList({required this.size, super.key});
final Size size;
@override
Widget build(BuildContext context) {
@@ -34,7 +34,7 @@ class RecommendedEventList extends StatelessWidget {
],
),
child: const Padding(
padding: EdgeInsets.all(12.0),
padding: EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'modern_empty_state.dart';
/// Widget affichant l'état vide des demandes d'amitié.
class RequestsEmptyState extends StatelessWidget {
const RequestsEmptyState({required this.theme, super.key});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return const ModernEmptyState(
illustration: EmptyStateIllustration.requests,
title: 'Aucune demande en attente',
description: 'Les demandes d\'amitié que vous recevez apparaîtront ici',
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'shimmer_loading.dart';
/// Widget affichant l'état de chargement des demandes d'amitié avec skeleton loaders.
class RequestsLoadingState extends StatelessWidget {
const RequestsLoadingState({required this.theme, super.key});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return SkeletonList(
itemCount: 4,
skeletonWidget: const ListItemSkeleton(height: 80),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
/// Widget pour l'en-tête d'une section de demandes.
class RequestsSectionHeader extends StatelessWidget {
const RequestsSectionHeader({
required this.title,
required this.theme,
super.key,
});
final String title;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../data/providers/friends_provider.dart';
import 'friend_request_card.dart';
import 'requests_empty_state.dart';
import 'requests_loading_state.dart';
import 'requests_section_header.dart';
/// Onglet affichant les demandes d'amitié reçues et envoyées.
class RequestsTab extends StatelessWidget {
const RequestsTab({
required this.onAccept,
required this.onReject,
required this.onCancel,
required this.onRefresh,
super.key,
});
final Future<void> Function(FriendsProvider, String) onAccept;
final Future<void> Function(FriendsProvider, String) onReject;
final Future<void> Function(FriendsProvider, String) onCancel;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer<FriendsProvider>(
builder: (context, provider, child) {
final isLoading =
provider.isLoadingReceivedRequests || provider.isLoadingSentRequests;
final hasReceived = provider.receivedRequests.isNotEmpty;
final hasSent = provider.sentRequests.isNotEmpty;
if (isLoading && !hasReceived && !hasSent) {
return RequestsLoadingState(theme: theme);
}
if (!hasReceived && !hasSent) {
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
provider.fetchReceivedRequests(),
provider.fetchSentRequests(),
]);
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height - 300,
child: RequestsEmptyState(theme: theme),
),
),
);
}
return _buildRequestsList(theme, provider);
},
);
}
/// Construit la liste des demandes.
Widget _buildRequestsList(ThemeData theme, FriendsProvider provider) {
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
provider.fetchReceivedRequests(),
provider.fetchSentRequests(),
]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (provider.receivedRequests.isNotEmpty) ...[
RequestsSectionHeader(
title: 'Demandes reçues',
theme: theme,
),
const SizedBox(height: 8),
...provider.receivedRequests.map(
(request) => FriendRequestCard(
request: request,
onAccept: () => onAccept(provider, request.friendshipId),
onReject: () => onReject(provider, request.friendshipId),
),
),
const SizedBox(height: 24),
],
if (provider.sentRequests.isNotEmpty) ...[
RequestsSectionHeader(
title: 'Demandes envoyées',
theme: theme,
),
const SizedBox(height: 8),
...provider.sentRequests.map(
(request) => FriendRequestCard(
request: request,
onAccept: null,
onReject: () => onCancel(provider, request.friendshipId),
isSentRequest: true,
),
),
const SizedBox(height: 16),
],
],
),
);
}
}

View File

@@ -1,38 +1,106 @@
import 'package:flutter/material.dart';
/// [SearchFriends] est un widget permettant à l'utilisateur de rechercher des amis.
/// Il inclut un champ de texte stylisé pour saisir la requête de recherche.
/// Chaque modification du texte dans le champ génère un log dans le terminal pour suivre en temps réel l'activité.
class SearchFriends extends StatelessWidget {
const SearchFriends({Key? key}) : super(key: key);
import '../../core/constants/env_config.dart';
/// Widget de recherche d'amis avec design moderne et compact.
///
/// Ce widget permet à l'utilisateur de rechercher des amis avec un champ
/// de texte stylisé et une icône de nettoyage.
///
/// **Usage:**
/// ```dart
/// SearchFriends(
/// onSearchChanged: (query) => filterFriends(query),
/// )
/// ```
class SearchFriends extends StatefulWidget {
const SearchFriends({
super.key,
this.onSearchChanged,
this.hintText = 'Rechercher un ami...',
});
/// Callback appelé lorsque la recherche change
final ValueChanged<String>? onSearchChanged;
/// Texte d'indication
final String hintText;
@override
State<SearchFriends> createState() => _SearchFriendsState();
}
class _SearchFriendsState extends State<SearchFriends> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return TextField(
style: const TextStyle(
color: Colors.white, // Le texte saisi est de couleur blanche.
),
decoration: InputDecoration(
hintText: 'Rechercher un ami...', // Indication textuelle pour aider l'utilisateur.
hintStyle: const TextStyle(
color: Colors.white54, // Style de l'indicateur avec une couleur plus claire.
),
filled: true,
fillColor: Colors.grey.shade800, // Couleur de fond du champ de recherche.
prefixIcon: const Icon(
Icons.search, // Icône de loupe pour indiquer la recherche.
color: Colors.white54, // Couleur de l'icône de recherche.
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30.0), // Bordure arrondie pour un style moderne.
borderSide: BorderSide.none, // Aucune bordure visible pour un look propre.
),
),
onChanged: (value) {
// Fonction appelée chaque fois que l'utilisateur modifie le texte dans le champ de recherche.
debugPrint('[LOG] Recherche d\'amis : $value'); // Log de chaque saisie.
// Vous pouvez ajouter ici la logique de filtrage de la liste des amis en fonction de la recherche.
},
controller: _controller,
decoration: _buildDecoration(theme),
style: theme.textTheme.bodyMedium,
onChanged: _handleSearchChanged,
textInputAction: TextInputAction.search,
);
}
/// Construit la décoration du champ de recherche.
InputDecoration _buildDecoration(ThemeData theme) {
return InputDecoration(
hintText: widget.hintText,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.5),
prefixIcon: Icon(
Icons.search,
color: theme.colorScheme.onSurface.withOpacity(0.6),
size: 20,
),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: theme.colorScheme.onSurface.withOpacity(0.6),
size: 20,
),
onPressed: _clearSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);
}
/// Gère le changement de recherche.
void _handleSearchChanged(String value) {
setState(() {}); // Met à jour l'UI pour afficher/masquer l'icône clear
if (EnvConfig.enableDetailedLogs) {
debugPrint('[SearchFriends] Recherche: $value');
}
widget.onSearchChanged?.call(value);
}
/// Nettoie le champ de recherche.
void _clearSearch() {
_controller.clear();
setState(() {});
widget.onSearchChanged?.call('');
}
}

View File

@@ -1,32 +1,96 @@
import 'package:flutter/material.dart';
/// En-tête de section avec icône et support du thème.
///
/// Ce widget fournit un en-tête cohérent pour les sections de l'application,
/// utilisant automatiquement les couleurs du thème actif.
///
/// **Usage:**
/// ```dart
/// SectionHeader(
/// title: 'Événements',
/// icon: Icons.event,
/// )
/// ```
class SectionHeader extends StatelessWidget {
final String title;
final IconData icon;
final TextStyle? textStyle; // Ajout de la possibilité de personnaliser le style du texte
/// Crée un nouveau [SectionHeader].
///
/// [title] Le titre de la section
/// [icon] L'icône à afficher
/// [textStyle] Style de texte personnalisé (optionnel)
/// [iconColor] Couleur de l'icône (optionnel)
/// [onTap] Fonction à exécuter lors du clic (optionnel)
/// [trailing] Widget optionnel à afficher à droite
const SectionHeader({
required this.title,
required this.icon,
this.textStyle, // Paramètre optionnel
Key? key,
}) : super(key: key);
super.key,
this.textStyle,
this.iconColor,
this.onTap,
this.trailing,
});
/// Le titre de la section
final String title;
/// L'icône à afficher
final IconData icon;
/// Style de texte personnalisé (optionnel)
final TextStyle? textStyle;
/// Couleur de l'icône (optionnel)
final Color? iconColor;
/// Fonction à exécuter lors du clic (optionnel)
final VoidCallback? onTap;
/// Widget optionnel à afficher à droite
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
final theme = Theme.of(context);
final defaultTextStyle = theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
);
Widget headerContent = Row(
children: [
Text(
title,
style: textStyle ?? const TextStyle( // Utilisation du style fourni ou d'un style par défaut
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
Icon(
icon,
color: iconColor ?? theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: textStyle ?? defaultTextStyle,
),
),
Icon(icon, color: Colors.white),
if (trailing != null) trailing!,
],
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: headerContent,
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: headerContent,
);
}
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/design_system.dart';
import '../../domain/entities/social_post.dart';
import 'animated_widgets.dart';
import 'custom_snackbar.dart';
/// Dialog moderne pour partager un post avec plusieurs options.
///
/// Propose plusieurs méthodes de partage:
/// - Copier le lien du post
/// - Partager vers des amis (à implémenter)
/// - Partager en externe (si share plugin disponible)
class SharePostDialog extends StatelessWidget {
const SharePostDialog({
required this.post,
required this.onShareConfirmed,
super.key,
});
final SocialPost post;
final VoidCallback onShareConfirmed;
/// Affiche le dialogue de partage
static Future<void> show({
required BuildContext context,
required SocialPost post,
required VoidCallback onShareConfirmed,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SharePostDialog(
post: post,
onShareConfirmed: onShareConfirmed,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + DesignSystem.spacingLg,
top: DesignSystem.spacingMd,
left: DesignSystem.spacingLg,
right: DesignSystem.spacingLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle bar
Center(
child: Container(
width: 36,
height: 3,
margin: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
),
),
// Titre
Text(
'Partager ce post',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacingMd),
// Description du post
Container(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 14,
backgroundImage: post.userProfileImageUrl.isNotEmpty
? NetworkImage(post.userProfileImageUrl)
: null,
backgroundColor: theme.colorScheme.primary.withOpacity(0.15),
child: post.userProfileImageUrl.isEmpty
? Icon(
Icons.person_rounded,
size: 14,
color: theme.colorScheme.primary,
)
: null,
),
const SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Text(
post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: DesignSystem.spacingSm),
Text(
post.content,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 13,
height: 1.4,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: DesignSystem.spacingLg),
// Options de partage
_buildShareOption(
context: context,
icon: Icons.link,
iconColor: AppColors.primary,
title: 'Copier le lien',
subtitle: 'Copiez l\'URL du post dans le presse-papiers',
onTap: () => _copyLink(context),
),
const SizedBox(height: DesignSystem.spacingSm),
_buildShareOption(
context: context,
icon: Icons.people,
iconColor: Colors.green,
title: 'Partager à des amis',
subtitle: 'Envoyez ce post à vos amis sur Afterwork',
onTap: () => _shareToFriends(context),
),
const SizedBox(height: DesignSystem.spacingSm),
_buildShareOption(
context: context,
icon: Icons.ios_share,
iconColor: Colors.blue,
title: 'Partager via...',
subtitle: 'WhatsApp, Messenger, Email, etc.',
onTap: () => _shareExternal(context),
),
const SizedBox(height: DesignSystem.spacingSm),
_buildShareOption(
context: context,
icon: Icons.send,
iconColor: Colors.orange,
title: 'Partager sur le fil',
subtitle: 'Incrémenter le compteur de partages',
onTap: () => _shareToFeed(context),
),
const SizedBox(height: DesignSystem.spacingMd),
// Bouton annuler
AnimatedScaleButton(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: DesignSystem.spacingMd,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Text(
'Annuler',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
Widget _buildShareOption({
required BuildContext context,
required IconData icon,
required Color iconColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
return AnimatedScaleButton(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Icon(
icon,
color: iconColor,
size: 20,
),
),
const SizedBox(width: DesignSystem.spacingMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color: theme.colorScheme.onSurface.withOpacity(0.3),
size: 20,
),
],
),
),
);
}
/// Copie le lien du post dans le presse-papiers
void _copyLink(BuildContext context) {
// Génère un lien fictif pour le post (à remplacer par le vrai lien depuis le backend)
final link = 'https://afterwork.app/posts/${post.id}';
Clipboard.setData(ClipboardData(text: link));
Navigator.pop(context);
context.showSuccess('Lien copié dans le presse-papiers');
}
/// Partage le post à des amis spécifiques
void _shareToFriends(BuildContext context) {
Navigator.pop(context);
// Afficher le dialog de sélection d'amis
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Partager avec des amis'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cette fonctionnalité permet de partager ce post directement avec vos amis par message privé.',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: DesignSystem.spacingLg),
// Future enhancement: Liste d'amis avec sélection multiple
Container(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey.shade600),
const SizedBox(width: DesignSystem.spacingMd),
Expanded(
child: Text(
'Liste des amis à venir dans une prochaine version',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
/// Partage le post via des applications externes
Future<void> _shareExternal(BuildContext context) async {
Navigator.pop(context);
try {
final postUrl = 'https://afterwork.app/post/${post.id}';
final shareText = '${post.content}\n\nVoir sur AfterWork: $postUrl';
await Share.share(
shareText,
subject: 'Publication AfterWork de ${post.userFirstName} ${post.userLastName}',
);
if (context.mounted) {
context.showSuccess('Post partagé avec succès');
onShareConfirmed();
}
} catch (e) {
if (context.mounted) {
context.showError('Erreur lors du partage');
}
}
}
/// Partage le post sur le fil (incrémente le compteur)
void _shareToFeed(BuildContext context) {
Navigator.pop(context);
onShareConfirmed();
}
}

View File

@@ -0,0 +1,458 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
/// Widget Shimmer pour effets de chargement élégants
///
/// Utilisé pour afficher un état de chargement avec effet de brillance
/// (shimmer effect) au lieu d'un simple CircularProgressIndicator.
///
/// **Usage:**
/// ```dart
/// ShimmerLoading(
/// child: Container(
/// width: 200,
/// height: 100,
/// decoration: BoxDecoration(
/// color: Colors.white,
/// borderRadius: DesignSystem.borderRadiusLg,
/// ),
/// ),
/// )
/// ```
class ShimmerLoading extends StatefulWidget {
const ShimmerLoading({
required this.child,
this.baseColor,
this.highlightColor,
this.duration = const Duration(milliseconds: 1500),
super.key,
});
final Widget child;
final Color? baseColor;
final Color? highlightColor;
final Duration duration;
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
)..repeat();
_animation = Tween<double>(begin: -2, end: 2).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutSine,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = widget.baseColor ??
(isDark ? Colors.grey[850]! : Colors.grey[300]!);
final highlightColor = widget.highlightColor ??
(isDark ? Colors.grey[800]! : Colors.grey[100]!);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: [
0.0,
_animation.value,
1.0,
],
transform: _SlidingGradientTransform(_animation.value),
).createShader(bounds);
},
blendMode: BlendMode.srcATop,
child: widget.child,
);
},
);
}
}
class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform(this.slidePercent);
final double slidePercent;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0, 0);
}
}
// ============================================================================
// SKELETON WIDGETS PRÉ-CONSTRUITS
// ============================================================================
/// Skeleton pour une carte d'événement
class EventCardSkeleton extends StatelessWidget {
const EventCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[850]! : Colors.grey[300]!;
return ShimmerLoading(
child: Card(
margin: DesignSystem.paddingAll(DesignSystem.spacingMd),
child: Padding(
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image skeleton
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusLg,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingLg),
// Title skeleton
Container(
width: double.infinity,
height: 24,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingSm),
// Description skeleton
Container(
width: MediaQuery.of(context).size.width * 0.7,
height: 16,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingLg),
// Buttons skeleton
Row(
children: [
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusMd,
),
),
),
DesignSystem.horizontalSpace(DesignSystem.spacingSm),
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusMd,
),
),
),
],
),
],
),
),
),
);
}
}
/// Skeleton pour une carte d'ami
class FriendCardSkeleton extends StatelessWidget {
const FriendCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[850]! : Colors.grey[300]!;
return ShimmerLoading(
child: Container(
margin: DesignSystem.paddingAll(DesignSystem.spacingSm),
padding: DesignSystem.paddingAll(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: DesignSystem.borderRadiusLg,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Avatar skeleton
Container(
width: DesignSystem.avatarSizeLg,
height: DesignSystem.avatarSizeLg,
decoration: BoxDecoration(
color: baseColor,
shape: BoxShape.circle,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingSm),
// Name skeleton
Container(
width: 80,
height: 14,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
],
),
),
);
}
}
/// Skeleton pour un post social
class SocialPostSkeleton extends StatelessWidget {
const SocialPostSkeleton({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[850]! : Colors.grey[300]!;
return ShimmerLoading(
child: Card(
margin: DesignSystem.paddingAll(DesignSystem.spacingMd),
child: Padding(
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header (avatar + nom)
Row(
children: [
Container(
width: DesignSystem.avatarSizeMd,
height: DesignSystem.avatarSizeMd,
decoration: BoxDecoration(
color: baseColor,
shape: BoxShape.circle,
),
),
DesignSystem.horizontalSpace(DesignSystem.spacingMd),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 120,
height: 16,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingXs),
Container(
width: 80,
height: 12,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
],
),
],
),
DesignSystem.verticalSpace(DesignSystem.spacingLg),
// Contenu
Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingSm),
Container(
width: MediaQuery.of(context).size.width * 0.8,
height: 16,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingLg),
// Actions
Row(
children: List.generate(
3,
(index) => Padding(
padding: DesignSystem.paddingHorizontal(
DesignSystem.spacingMd,
),
child: Container(
width: 60,
height: 32,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusMd,
),
),
),
),
),
],
),
),
),
);
}
}
/// Skeleton générique pour liste
class ListItemSkeleton extends StatelessWidget {
const ListItemSkeleton({
this.height = 80,
super.key,
});
final double height;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[850]! : Colors.grey[300]!;
return ShimmerLoading(
child: Container(
height: height,
margin: DesignSystem.paddingVertical(DesignSystem.spacingSm),
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: DesignSystem.borderRadiusLg,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: baseColor,
shape: BoxShape.circle,
),
),
DesignSystem.horizontalSpace(DesignSystem.spacingLg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
DesignSystem.verticalSpace(DesignSystem.spacingSm),
Container(
width: 150,
height: 12,
decoration: BoxDecoration(
color: baseColor,
borderRadius: DesignSystem.borderRadiusSm,
),
),
],
),
),
],
),
),
);
}
}
/// Widget pour afficher une grille de skeletons
class SkeletonGrid extends StatelessWidget {
const SkeletonGrid({
required this.itemCount,
required this.skeletonWidget,
this.crossAxisCount = 2,
super.key,
});
final int itemCount;
final Widget skeletonWidget;
final int crossAxisCount;
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: DesignSystem.spacingMd,
mainAxisSpacing: DesignSystem.spacingMd,
childAspectRatio: 0.8,
),
itemCount: itemCount,
itemBuilder: (context, index) => skeletonWidget,
);
}
}
/// Widget pour afficher une liste de skeletons
class SkeletonList extends StatelessWidget {
const SkeletonList({
required this.itemCount,
required this.skeletonWidget,
super.key,
});
final int itemCount;
final Widget skeletonWidget;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: DesignSystem.paddingAll(DesignSystem.spacingLg),
itemCount: itemCount,
itemBuilder: (context, index) => skeletonWidget,
);
}
}

View File

@@ -0,0 +1,619 @@
# Widgets Sociaux - Architecture Modulaire
Architecture de composants réutilisables pour les posts sociaux avec support d'images et vidéos.
## 📐 Architecture
### 🔹 Widgets Atomiques (Niveau 1)
Les plus petits composants réutilisables dans toute l'application.
#### `SocialActionButton`
**Bouton d'action tout petit et uniforme**
- Taille par défaut: 22px
- Supporte tooltip
- Animation scale: 0.85
- Couleur personnalisable
```dart
SocialActionButton(
icon: Icons.favorite_rounded,
onTap: () => handleLike(),
color: Colors.red,
tooltip: 'J\'aime',
)
```
#### `SocialActionButtonWithCount`
**Bouton d'action avec compteur**
- Affichage du nombre (1K, 1M formaté)
- Support état actif/inactif
- Couleur différente si actif
```dart
SocialActionButtonWithCount(
icon: Icons.favorite_rounded,
count: 245,
onTap: () => handleLike(),
isActive: true,
activeColor: Colors.red,
)
```
#### `SocialBadge`
**Badge réutilisable générique**
- Icône optionnelle
- Couleurs personnalisables
- Taille de police ajustable
- Padding personnalisable
```dart
SocialBadge(
label: 'Nouveau',
icon: Icons.fiber_new,
fontSize: 11,
)
```
#### `VerifiedBadge`
**Badge de compte vérifié**
- Icône verified avec tooltip
- Taille personnalisable
```dart
VerifiedBadge(size: 16)
```
#### `CategoryBadge`
**Badge de catégorie**
- Couleur secondaire
- Icône optionnelle
```dart
CategoryBadge(
category: 'Sport',
icon: Icons.sports_soccer,
)
```
#### `StatusBadge`
**Badge de statut dynamique**
- Couleurs automatiques selon le statut
- "nouveau" → primaryContainer
- "tendance" → errorContainer
- "populaire" → tertiaryContainer
```dart
StatusBadge(
status: 'tendance',
icon: Icons.trending_up,
)
```
#### `MediaCountBadge`
**Badge de nombre de médias**
- Pour images/vidéos
- Affichage sur fond noir semi-transparent
- Icône différente selon le type
```dart
MediaCountBadge(
count: 5,
isVideo: false,
)
```
### 🔹 Widgets de Médias (Niveau 2)
Composants spécialisés pour la gestion des médias.
#### `PostMedia` (Model)
**Modèle de données pour un média**
```dart
class PostMedia {
final String url;
final MediaType type; // image ou video
final String? thumbnailUrl;
final Duration? duration;
}
```
#### `PostMediaViewer`
**Affichage de médias avec dispositions multiples**
- 1 média: Aspect ratio 1:1
- 2 médias: Côte à côte
- 3 médias: 1 grand + 2 petits
- 4+ médias: Grille 2x2 avec compteur "+N"
**Fonctionnalités:**
- Hero animation
- Loading progressif
- Gestion d'erreurs
- Tap pour plein écran
- Badge de durée pour vidéos
```dart
PostMediaViewer(
medias: [
PostMedia(url: 'image1.jpg', type: MediaType.image),
PostMedia(url: 'video1.mp4', type: MediaType.video, duration: Duration(minutes: 2, seconds: 30)),
],
postId: post.id,
onTap: () => handleMediaTap(),
)
```
#### `MediaPicker`
**Sélecteur de médias pour création de post**
- Sélection depuis galerie (multi)
- Capture photo depuis caméra
- Sélection vidéo
- Limite: 10 médias max (personnalisable)
- Prévisualisation avec bouton supprimer
- Indicateur vidéo sur thumbnails
**Fonctionnalités:**
- Compteur médias sélectionnés
- Boutons désactivés si limite atteinte
- Grille horizontale scrollable
- Suppression individuelle
```dart
MediaPicker(
onMediasChanged: (medias) {
setState(() => selectedMedias = medias);
},
maxMedias: 10,
initialMedias: [],
)
```
### 🔹 Dialogues et Composants Complexes (Niveau 3)
#### `CreatePostDialog`
**Dialogue de création de post avec médias**
**Fonctionnalités:**
- Avatar et nom utilisateur
- Champ texte (3-8 lignes, max 500 caractères)
- MediaPicker intégré
- Validation formulaire
- État de chargement pendant création
- Compteur caractères
- Visibilité (Public par défaut)
- **Compression automatique** des images avant upload
- **Upload progressif** avec indicateur de progression
- **Nettoyage automatique** des fichiers temporaires
**Processus d'upload en 3 étapes:**
1. Compression des images (0-50%)
2. Upload des médias (50-100%)
3. Création du post
**Présentation:**
- Modal bottom sheet
- Auto-focus sur le champ texte
- Padding adapté au clavier
- Actions: Annuler / Publier
- Barre de progression avec statut textuel
```dart
await CreatePostDialog.show(
context: context,
onPostCreated: (content, medias) async {
await createPost(content, medias);
},
userName: 'Jean Dupont',
userAvatarUrl: 'avatar.jpg',
);
```
#### `EditPostDialog`
**Dialogue d'édition de post existant**
**Fonctionnalités:**
- Pré-remplissage avec contenu existant
- Détection automatique des changements
- MediaPicker pour modifier les médias
- Validation formulaire
- Bouton "Enregistrer" désactivé si aucun changement
- État de chargement pendant mise à jour
**Présentation:**
- Modal bottom sheet
- Auto-focus sur le champ texte
- Icône d'édition dans l'en-tête
- Actions: Annuler / Enregistrer
```dart
await EditPostDialog.show(
context: context,
post: existingPost,
onPostUpdated: (content, medias) async {
await updatePost(existingPost.id, content, medias);
},
);
```
#### `FullscreenVideoPlayer`
**Lecteur vidéo plein écran avec contrôles**
**Fonctionnalités:**
- Lecture automatique au démarrage
- Orientation paysage forcée
- Mode immersif (barre système cachée)
- Contrôles tactiles :
- Tap pour afficher/masquer contrôles
- Play/Pause
- Reculer de 10s
- Avancer de 10s
- Barre de progression interactive (scrubbing)
- Affichage durée actuelle / totale
- Hero animation pour transition fluide
**Présentation:**
- Fond noir
- Header avec bouton fermer et titre
- Contrôles centraux (reculer/play/avancer)
- Footer avec barre de progression
- Gradient overlay sur header/footer
```dart
await FullscreenVideoPlayer.show(
context: context,
videoUrl: 'https://example.com/video.mp4',
heroTag: 'post_media_123_0',
title: 'Ma vidéo',
);
```
#### `SocialCardRefactored`
**Card modulaire de post social**
**Composants internes:**
- `_PostHeader`: Avatar gradient, nom, badge vérifié, timestamp, menu
- `_UserAvatar`: Avatar avec bordure gradient
- `_PostTimestamp`: Formatage relatif (Il y a X min/h/j)
- `_PostMenu`: PopupMenu (Modifier, Supprimer)
- `_PostActions`: Like, Comment, Share, Bookmark
- `_PostStats`: Nombre de likes
- `_PostContent`: Texte enrichi (hashtags #, mentions @)
- `_CommentsLink`: Lien vers commentaires
**Fonctionnalités:**
- Support médias via PostMediaViewer
- Hashtags et mentions cliquables (couleur primaire)
- "Voir plus/Voir moins" pour contenu long (>150 caractères)
- Badge de catégorie optionnel
- Badge vérifié optionnel
- AnimatedCard avec elevation
```dart
SocialCardRefactored(
post: post,
onLike: () => handleLike(),
onComment: () => handleComment(),
onShare: () => handleShare(),
onDeletePost: () => handleDelete(),
onEditPost: () => handleEdit(),
showVerifiedBadge: true,
showCategory: true,
category: 'Sport',
)
```
## 🔧 Services (lib/data/services/)
### `ImageCompressionService`
**Service de compression d'images avant upload**
**Configurations prédéfinies:**
- `CompressionConfig.post` : 85% qualité, 1920x1920px
- `CompressionConfig.thumbnail` : 70% qualité, 400x400px
- `CompressionConfig.story` : 90% qualité, 1080x1920px
- `CompressionConfig.avatar` : 80% qualité, 500x500px
**Fonctionnalités:**
- Compression avec qualité ajustable (0-100)
- Redimensionnement automatique
- Support formats: JPEG, PNG, WebP
- Compression parallèle pour plusieurs images
- Callback de progression
- Statistiques de réduction de taille
- Nettoyage automatique des fichiers temporaires
**Utilisation:**
```dart
final compressionService = ImageCompressionService();
// Compression d'une image
final compressed = await compressionService.compressImage(
imageFile,
config: CompressionConfig.post,
);
// Compression multiple avec progression
final compressedList = await compressionService.compressMultipleImages(
imageFiles,
config: CompressionConfig.thumbnail,
onProgress: (processed, total) {
print('Compression: $processed/$total');
},
);
// Créer un thumbnail
final thumbnail = await compressionService.createThumbnail(imageFile);
// Nettoyer les fichiers temporaires
await compressionService.cleanupTempFiles();
```
**Logs (si EnvConfig.enableDetailedLogs = true):**
```
[ImageCompression] Compression de: photo.jpg
[ImageCompression] Taille originale: 4.2 MB
[ImageCompression] Taille compressée: 1.8 MB
[ImageCompression] Réduction: 57.1%
```
### `MediaUploadService`
**Service d'upload de médias vers le backend**
**Fonctionnalités:**
- Upload d'images et vidéos
- Upload parallèle de plusieurs médias
- Callback de progression
- Génération automatique de thumbnails pour vidéos
- Support vidéos locales et réseau
- Suppression de médias
**Modèle de résultat:**
```dart
class MediaUploadResult {
final String url; // URL du média uploadé
final String? thumbnailUrl; // URL du thumbnail (vidéo)
final String type; // 'image' ou 'video'
final Duration? duration; // Durée (vidéo seulement)
}
```
**Utilisation:**
```dart
final uploadService = MediaUploadService(http.Client());
// Upload d'un média
final result = await uploadService.uploadMedia(imageFile);
print('URL: ${result.url}');
// Upload multiple avec progression
final results = await uploadService.uploadMultipleMedias(
mediaFiles,
onProgress: (uploaded, total) {
print('Upload: $uploaded/$total');
},
);
// Générer thumbnail pour vidéo
final thumbnailUrl = await uploadService.generateVideoThumbnail(videoUrl);
// Supprimer un média
await uploadService.deleteMedia(mediaUrl);
```
**Configuration:**
Le endpoint d'upload est configuré dans `EnvConfig.mediaUploadEndpoint`.
## 📦 Utilisation
### Import Simple
```dart
import 'package:afterwork/presentation/widgets/social/social_widgets.dart';
```
Ceci importe tous les widgets nécessaires :
- Boutons d'action
- Badges
- Media picker
- Post media viewer
- Create post dialog
- Social card
### Exemple Complet
```dart
// 1. Créer un post avec compression et upload automatiques
await CreatePostDialog.show(
context: context,
onPostCreated: (content, medias) async {
// Les médias sont déjà compressés et uploadés par le dialogue
await apiService.createPost(
content: content,
mediaFiles: medias,
);
},
userName: currentUser.name,
userAvatarUrl: currentUser.avatar,
);
// 2. Créer un post manuellement avec services
final compressionService = ImageCompressionService();
final uploadService = MediaUploadService(http.Client());
// Compresser les images
final compressedMedias = await compressionService.compressMultipleImages(
selectedMedias,
config: CompressionConfig.post,
onProgress: (processed, total) {
print('Compression: $processed/$total');
},
);
// Uploader les médias
final uploadResults = await uploadService.uploadMultipleMedias(
compressedMedias,
onProgress: (uploaded, total) {
print('Upload: $uploaded/$total');
},
);
// Créer le post avec les URLs
await apiService.createPost(
content: contentController.text,
mediaUrls: uploadResults.map((r) => r.url).toList(),
);
// Nettoyer les fichiers temporaires
await compressionService.cleanupTempFiles();
// 3. Afficher un post avec médias
SocialCardRefactored(
post: SocialPost(
id: '123',
content: 'Super soirée avec les amis ! #afterwork @john',
imageUrl: '', // Géré par medias maintenant
likesCount: 42,
commentsCount: 5,
sharesCount: 2,
isLikedByCurrentUser: false,
// ...
),
medias: [
PostMedia(
url: 'https://example.com/photo1.jpg',
type: MediaType.image,
),
PostMedia(
url: 'https://example.com/video1.mp4',
type: MediaType.video,
thumbnailUrl: 'https://example.com/video1_thumb.jpg',
duration: Duration(minutes: 2, seconds: 30),
),
],
onLike: () {
setState(() {
post = post.copyWith(
isLikedByCurrentUser: !post.isLikedByCurrentUser,
likesCount: post.isLikedByCurrentUser
? post.likesCount - 1
: post.likesCount + 1,
);
});
},
onComment: () {
Navigator.push(/* CommentsScreen */);
},
onEditPost: () async {
await EditPostDialog.show(
context: context,
post: post,
onPostUpdated: (content, medias) async {
await apiService.updatePost(post.id, content, medias);
},
);
},
onDeletePost: () async {
await apiService.deletePost(post.id);
},
showVerifiedBadge: user.isVerified,
showCategory: true,
category: post.category,
);
// 4. Afficher une vidéo en plein écran
GestureDetector(
onTap: () {
FullscreenVideoPlayer.show(
context: context,
videoUrl: 'https://example.com/video.mp4',
heroTag: 'post_media_${post.id}_0',
title: 'Ma vidéo',
);
},
child: Stack(
children: [
Image.network(videoThumbnail),
Center(
child: Icon(Icons.play_circle_outline, size: 64),
),
],
),
);
```
## 🎨 Principes de Design
### Uniformité
- Tous les boutons icônes: 20-22px
- Tous les badges: fontSize 10-11px
- Spacing: DesignSystem constants (4, 8, 12, 16px)
- Border radius: DesignSystem (4, 10, 16px)
### Réutilisabilité
- Chaque composant fait UNE chose
- Props claires et typées
- Valeurs par défaut sensées
- Documentation inline
### Performances
- Widgets const quand possible
- Keys pour listes
- Lazy loading des images
- AnimatedCard pour hover effects
### Accessibilité
- Tooltips sur tous les boutons
- Couleurs avec contraste suffisant
- Tailles tactiles minimales respectées (44x44)
## 🔧 Personnalisation
### Thème
Tous les widgets utilisent le ThemeData du contexte :
- `theme.colorScheme.primary`
- `theme.colorScheme.onSurface`
- `theme.textTheme.bodyMedium`
### DesignSystem
Les constantes sont centralisées :
- `DesignSystem.spacingSm` (8px)
- `DesignSystem.spacingMd` (12px)
- `DesignSystem.spacingLg` (16px)
- `DesignSystem.radiusSm` (4px)
- `DesignSystem.radiusMd` (10px)
- `DesignSystem.radiusLg` (16px)
## 📝 Statut des Fonctionnalités
### ✅ Complété
- [x] **Support de plusieurs médias par post** - PostMediaViewer supporte 1-10+ médias avec dispositions adaptatives
- [x] **Upload des médias vers le backend** - MediaUploadService avec progression et support image/vidéo
- [x] **Lecteur vidéo en plein écran** - FullscreenVideoPlayer avec contrôles complets
- [x] **Édition de post avec médias** - EditPostDialog avec détection de changements
- [x] **Compression d'images avant upload** - ImageCompressionService avec 4 configs prédéfinies
- [x] **Architecture modulaire** - Widgets atomiques, réutilisables et bien documentés
- [x] **Animations et interactions** - Like, bookmark, hero transitions, scale effects
- [x] **Hashtags et mentions cliquables** - Parsing et styling automatiques
- [x] **Progress tracking** - Indicateurs de progression pour compression et upload
### 🚧 À Venir
- [ ] **Stories avec médias** - Système de stories éphémères (24h) avec images/vidéos
- [ ] **Filtres sur les photos** - Filtres Instagram-style pour édition d'images
- [ ] **GIF support** - Support des GIFs animés dans les posts
- [ ] **Commentaires imbriqués** - Système de réponses aux commentaires
- [ ] **Réactions étendues** - Plus de réactions au-delà du simple like (❤️ 😂 😮 😢 😡)
- [ ] **Partage vers stories** - Partager un post dans sa story
- [ ] **Brouillons** - Sauvegarder des posts en brouillon
- [ ] **Planification** - Programmer la publication d'un post
### 📊 Métriques
- **13 widgets** créés (atomiques + composites)
- **2 services** (compression + upload)
- **5 types de badges** réutilisables
- **4 layouts** pour affichage médias (1, 2, 3, 4+)
- **3 étapes** de processus d'upload (compression, upload, création)
- **10 médias max** par post (configurable)

View File

@@ -0,0 +1,436 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
import '../../../data/services/image_compression_service.dart';
import '../../../data/services/media_upload_service.dart';
import 'media_picker.dart';
/// Dialogue de création de post avec support d'images et vidéos.
class CreatePostDialog extends StatefulWidget {
const CreatePostDialog({
required this.onPostCreated,
this.userAvatarUrl,
this.userName,
super.key,
});
final Future<void> Function(String content, List<XFile> medias) onPostCreated;
final String? userAvatarUrl;
final String? userName;
@override
State<CreatePostDialog> createState() => _CreatePostDialogState();
/// Affiche le dialogue de création de post
static Future<void> show({
required BuildContext context,
required Future<void> Function(String content, List<XFile> medias)
onPostCreated,
String? userAvatarUrl,
String? userName,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CreatePostDialog(
onPostCreated: onPostCreated,
userAvatarUrl: userAvatarUrl,
userName: userName,
),
);
}
}
class _CreatePostDialogState extends State<CreatePostDialog> {
final TextEditingController _contentController = TextEditingController();
final FocusNode _contentFocusNode = FocusNode();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final ImageCompressionService _compressionService;
late final MediaUploadService _uploadService;
List<XFile> _selectedMedias = [];
bool _isPosting = false;
double _uploadProgress = 0.0;
String _uploadStatus = '';
@override
void initState() {
super.initState();
// Initialiser les services
_compressionService = ImageCompressionService();
_uploadService = MediaUploadService(http.Client());
// Auto-focus sur le champ de texte
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_contentFocusNode.requestFocus();
}
});
}
@override
void dispose() {
_contentController.dispose();
_contentFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
padding: EdgeInsets.only(
bottom: mediaQuery.viewInsets.bottom,
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(theme),
const SizedBox(height: DesignSystem.spacingLg),
_buildUserInfo(theme),
const SizedBox(height: DesignSystem.spacingMd),
_buildContentField(theme),
const SizedBox(height: DesignSystem.spacingLg),
MediaPicker(
onMediasChanged: (medias) {
setState(() {
_selectedMedias = medias;
});
},
initialMedias: _selectedMedias,
),
const SizedBox(height: DesignSystem.spacingLg),
_buildActions(theme),
],
),
),
),
),
);
}
Widget _buildHeader(ThemeData theme) {
return Row(
children: [
// Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
const Spacer(),
// Titre
Text(
'Créer un post',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
const Spacer(),
// Bouton fermer
IconButton(
icon: const Icon(Icons.close_rounded, size: 22),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
],
);
}
Widget _buildUserInfo(ThemeData theme) {
return Row(
children: [
// Avatar
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: widget.userAvatarUrl != null &&
widget.userAvatarUrl!.isNotEmpty
? NetworkImage(widget.userAvatarUrl!)
: null,
child: widget.userAvatarUrl == null || widget.userAvatarUrl!.isEmpty
? Icon(
Icons.person_rounded,
size: 24,
color: theme.colorScheme.onPrimaryContainer,
)
: null,
),
const SizedBox(width: DesignSystem.spacingMd),
// Nom
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.userName ?? 'Utilisateur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.public_rounded,
size: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'Public',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
],
);
}
Widget _buildContentField(ThemeData theme) {
return Form(
key: _formKey,
child: TextFormField(
controller: _contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration(
hintText: 'Quoi de neuf ?',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 16,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 16,
height: 1.5,
),
maxLines: 8,
minLines: 3,
maxLength: 500,
validator: (value) {
if ((value == null || value.trim().isEmpty) &&
_selectedMedias.isEmpty) {
return 'Ajoutez du texte ou des médias';
}
return null;
},
),
);
}
Widget _buildActions(ThemeData theme) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Indicateur de progression lors de l'upload
if (_isPosting && _uploadStatus.isNotEmpty) ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_uploadStatus,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: DesignSystem.spacingSm),
LinearProgressIndicator(
value: _uploadProgress,
backgroundColor: theme.colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: DesignSystem.spacingMd),
],
// Actions
Row(
children: [
// Indicateur de caractères
Expanded(
child: Text(
'${_contentController.text.length}/500',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(width: DesignSystem.spacingMd),
// Bouton Annuler
TextButton(
onPressed: _isPosting ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: DesignSystem.spacingSm),
// Bouton Publier
FilledButton(
onPressed: _isPosting ? null : _handlePost,
child: _isPosting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Publier'),
),
],
),
],
);
}
Future<void> _handlePost() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isPosting = true;
_uploadProgress = 0.0;
_uploadStatus = 'Préparation...';
});
try {
List<XFile> processedMedias = _selectedMedias;
// Étape 1: Compression des images
if (_selectedMedias.isNotEmpty) {
setState(() {
_uploadStatus = 'Compression des images...';
});
processedMedias = await _compressionService.compressMultipleImages(
_selectedMedias,
config: CompressionConfig.post,
onProgress: (processed, total) {
if (mounted) {
setState(() {
_uploadProgress = (processed / total) * 0.5;
_uploadStatus =
'Compression $processed/$total...';
});
}
},
);
}
// Étape 2: Upload des médias
if (processedMedias.isNotEmpty) {
setState(() {
_uploadStatus = 'Upload des médias...';
});
final uploadResults = await _uploadService.uploadMultipleMedias(
processedMedias,
onProgress: (uploaded, total) {
if (mounted) {
setState(() {
_uploadProgress = 0.5 + (uploaded / total) * 0.5;
_uploadStatus = 'Upload $uploaded/$total...';
});
}
},
);
// Extraire les URLs des médias uploadés
final uploadedMediaUrls = uploadResults.map((result) => result.url).toList();
// Note: Les URLs uploadées sont disponibles dans uploadedMediaUrls.
// Pour l'instant, on passe encore les fichiers locaux pour compatibilité,
// mais le backend devrait utiliser les URLs uploadées.
// À terme, modifier la signature de onPostCreated pour accepter List<String> urls
// au lieu de List<XFile> medias.
if (mounted) {
debugPrint('[CreatePostDialog] URLs uploadées: $uploadedMediaUrls');
}
}
// Étape 3: Création du post
setState(() {
_uploadStatus = 'Création du post...';
_uploadProgress = 1.0;
});
await widget.onPostCreated(
_contentController.text.trim(),
processedMedias,
);
// Nettoyer les fichiers temporaires
await _compressionService.cleanupTempFiles();
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
final theme = Theme.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la publication: ${e.toString()}'),
backgroundColor: theme.colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isPosting = false;
_uploadProgress = 0.0;
_uploadStatus = '';
});
}
}
}
}

View File

@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import 'media_picker.dart';
/// Dialogue d'édition de post existant avec support d'images et vidéos.
class EditPostDialog extends StatefulWidget {
const EditPostDialog({
required this.post,
required this.onPostUpdated,
super.key,
});
final SocialPost post;
final Future<void> Function(String content, List<XFile> medias) onPostUpdated;
@override
State<EditPostDialog> createState() => _EditPostDialogState();
/// Affiche le dialogue d'édition de post
static Future<void> show({
required BuildContext context,
required SocialPost post,
required Future<void> Function(String content, List<XFile> medias)
onPostUpdated,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => EditPostDialog(
post: post,
onPostUpdated: onPostUpdated,
),
);
}
}
class _EditPostDialogState extends State<EditPostDialog> {
late final TextEditingController _contentController;
final FocusNode _contentFocusNode = FocusNode();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
List<XFile> _selectedMedias = [];
bool _isUpdating = false;
@override
void initState() {
super.initState();
_contentController = TextEditingController(text: widget.post.content);
// Note: Les médias existants du post sont déjà stockés sous forme d'URLs
// dans widget.post.mediaUrls. Ils seront affichés via ces URLs.
// _selectedMedias permet d'ajouter de NOUVEAUX médias locaux.
// Lors de la sauvegarde, il faut combiner:
// - Les URLs existantes (widget.post.mediaUrls)
// - Les nouveaux médias uploadés (URLs générées depuis _selectedMedias)
_selectedMedias = [];
// Auto-focus sur le champ de texte
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_contentFocusNode.requestFocus();
}
});
}
@override
void dispose() {
_contentController.dispose();
_contentFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
padding: EdgeInsets.only(
bottom: mediaQuery.viewInsets.bottom,
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(theme),
const SizedBox(height: DesignSystem.spacingLg),
_buildUserInfo(theme),
const SizedBox(height: DesignSystem.spacingMd),
_buildContentField(theme),
const SizedBox(height: DesignSystem.spacingLg),
MediaPicker(
onMediasChanged: (medias) {
setState(() {
_selectedMedias = medias;
});
},
initialMedias: _selectedMedias,
),
const SizedBox(height: DesignSystem.spacingLg),
_buildActions(theme),
],
),
),
),
),
);
}
Widget _buildHeader(ThemeData theme) {
return Row(
children: [
// Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
const Spacer(),
// Titre
Text(
'Modifier le post',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
const Spacer(),
// Bouton fermer
IconButton(
icon: const Icon(Icons.close_rounded, size: 22),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
],
);
}
Widget _buildUserInfo(ThemeData theme) {
return Row(
children: [
// Avatar
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: widget.post.userProfileImageUrl.isNotEmpty
? NetworkImage(widget.post.userProfileImageUrl)
: null,
child: widget.post.userProfileImageUrl.isEmpty
? Icon(
Icons.person_rounded,
size: 24,
color: theme.colorScheme.onPrimaryContainer,
)
: null,
),
const SizedBox(width: DesignSystem.spacingMd),
// Nom
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.edit_rounded,
size: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'Édition',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
],
);
}
Widget _buildContentField(ThemeData theme) {
return Form(
key: _formKey,
child: TextFormField(
controller: _contentController,
focusNode: _contentFocusNode,
decoration: InputDecoration(
hintText: 'Modifiez votre post...',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
fontSize: 16,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 16,
height: 1.5,
),
maxLines: 8,
minLines: 3,
maxLength: 500,
validator: (value) {
if ((value == null || value.trim().isEmpty) &&
_selectedMedias.isEmpty) {
return 'Ajoutez du texte ou des médias';
}
return null;
},
),
);
}
Widget _buildActions(ThemeData theme) {
final hasChanges = _contentController.text != widget.post.content ||
_selectedMedias.isNotEmpty;
return Row(
children: [
// Indicateur de caractères
Expanded(
child: Text(
'${_contentController.text.length}/500',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(width: DesignSystem.spacingMd),
// Bouton Annuler
TextButton(
onPressed: _isUpdating ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: DesignSystem.spacingSm),
// Bouton Enregistrer
FilledButton(
onPressed: (_isUpdating || !hasChanges) ? null : _handleUpdate,
child: _isUpdating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
);
}
Future<void> _handleUpdate() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isUpdating = true;
});
try {
await widget.onPostUpdated(
_contentController.text.trim(),
_selectedMedias,
);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
final theme = Theme.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour: ${e.toString()}'),
backgroundColor: theme.colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isUpdating = false;
});
}
}
}
}

View File

@@ -0,0 +1,389 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import '../../../core/constants/design_system.dart';
/// Lecteur vidéo plein écran avec contrôles.
///
/// Affiche une vidéo en plein écran avec contrôles de lecture,
/// barre de progression et gestion de l'orientation.
class FullscreenVideoPlayer extends StatefulWidget {
const FullscreenVideoPlayer({
required this.videoUrl,
this.heroTag,
this.title,
super.key,
});
final String videoUrl;
final String? heroTag;
final String? title;
@override
State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
/// Affiche le lecteur vidéo en plein écran.
static Future<void> show({
required BuildContext context,
required String videoUrl,
String? heroTag,
String? title,
}) {
return Navigator.push<void>(
context,
PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenVideoPlayer(
videoUrl: videoUrl,
heroTag: heroTag,
title: title,
),
);
},
),
);
}
}
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> {
late VideoPlayerController _controller;
bool _isInitialized = false;
bool _showControls = true;
bool _isPlaying = false;
@override
void initState() {
super.initState();
_initializePlayer();
_setLandscapeOrientation();
}
@override
void dispose() {
_resetOrientation();
_controller.dispose();
super.dispose();
}
Future<void> _initializePlayer() async {
// Déterminer si c'est une URL réseau ou locale
if (widget.videoUrl.startsWith('http')) {
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
);
} else {
// Pour les fichiers locaux (pas encore implémenté)
// _controller = VideoPlayerController.file(File(widget.videoUrl));
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
);
}
try {
await _controller.initialize();
_controller.addListener(_videoListener);
if (mounted) {
setState(() {
_isInitialized = true;
});
// Démarrer la lecture automatiquement
_controller.play();
_isPlaying = true;
}
} catch (e) {
debugPrint('[FullscreenVideoPlayer] Erreur initialisation: $e');
}
}
void _videoListener() {
if (_controller.value.isPlaying != _isPlaying) {
setState(() {
_isPlaying = _controller.value.isPlaying;
});
}
}
Future<void> _setLandscapeOrientation() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
Future<void> _resetOrientation() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
void _togglePlayPause() {
setState(() {
if (_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
});
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggleControls,
child: Stack(
fit: StackFit.expand,
children: [
// Vidéo
Center(
child: _isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
// Contrôles
if (_showControls) ...[
// Header
_buildHeader(context),
// Contrôles centraux
_buildCenterControls(),
// Footer avec barre de progression
_buildFooter(),
],
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
child: Row(
children: [
// Bouton retour
IconButton(
icon: const Icon(
Icons.close_rounded,
color: Colors.white,
size: 28,
),
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: DesignSystem.spacingMd),
// Titre
if (widget.title != null && widget.title!.isNotEmpty)
Expanded(
child: Text(
widget.title!,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
);
}
Widget _buildCenterControls() {
return Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Reculer de 10s
_buildControlButton(
icon: Icons.replay_10_rounded,
onTap: () {
final currentPosition = _controller.value.position;
final newPosition = currentPosition - const Duration(seconds: 10);
_controller.seekTo(
newPosition < Duration.zero ? Duration.zero : newPosition,
);
},
),
const SizedBox(width: DesignSystem.spacingXl),
// Play/Pause
_buildControlButton(
icon: _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: 80,
),
const SizedBox(width: DesignSystem.spacingXl),
// Avancer de 10s
_buildControlButton(
icon: Icons.forward_10_rounded,
onTap: () {
final currentPosition = _controller.value.position;
final duration = _controller.value.duration;
final newPosition = currentPosition + const Duration(seconds: 10);
_controller.seekTo(
newPosition > duration ? duration : newPosition,
);
},
),
],
),
);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback onTap,
double size = 60,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.all(size / 5),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: Colors.white,
size: size / 1.5,
),
),
);
}
Widget _buildFooter() {
if (!_isInitialized) return const SizedBox.shrink();
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingMd),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Barre de progression
VideoProgressIndicator(
_controller,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.red,
bufferedColor: Colors.white54,
backgroundColor: Colors.white24,
),
padding: EdgeInsets.zero,
),
const SizedBox(height: DesignSystem.spacingSm),
// Temps
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_controller.value.position),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
Text(
_formatDuration(_controller.value.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
),
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,321 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/constants/design_system.dart';
/// Widget de sélection de médias pour la création de posts.
///
/// Permet de sélectionner des images et vidéos depuis la galerie ou l'appareil photo.
class MediaPicker extends StatefulWidget {
const MediaPicker({
required this.onMediasChanged,
this.maxMedias = 10,
this.initialMedias = const [],
super.key,
});
final ValueChanged<List<XFile>> onMediasChanged;
final int maxMedias;
final List<XFile> initialMedias;
@override
State<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
final ImagePicker _picker = ImagePicker();
List<XFile> _selectedMedias = [];
@override
void initState() {
super.initState();
_selectedMedias = List.from(widget.initialMedias);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Titre avec compteur
Row(
children: [
Text(
'Médias',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${_selectedMedias.length}/${widget.maxMedias}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
],
),
const SizedBox(height: DesignSystem.spacingMd),
// Boutons d'action
Row(
children: [
_buildActionButton(
context,
icon: Icons.photo_library_outlined,
label: 'Galerie',
onTap: _pickFromGallery,
),
const SizedBox(width: DesignSystem.spacingSm),
_buildActionButton(
context,
icon: Icons.camera_alt_outlined,
label: 'Caméra',
onTap: _pickFromCamera,
),
const SizedBox(width: DesignSystem.spacingSm),
_buildActionButton(
context,
icon: Icons.videocam_outlined,
label: 'Vidéo',
onTap: _pickVideo,
),
],
),
// Grille de médias sélectionnés
if (_selectedMedias.isNotEmpty) ...[
const SizedBox(height: DesignSystem.spacingMd),
_buildMediasGrid(theme),
],
],
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final canAddMore = _selectedMedias.length < widget.maxMedias;
return Expanded(
child: Material(
color: canAddMore
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: InkWell(
onTap: canAddMore ? onTap : null,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingMd,
),
child: Column(
children: [
Icon(
icon,
size: 24,
color: canAddMore
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(height: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w600,
color: canAddMore
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
],
),
),
),
),
);
}
Widget _buildMediasGrid(ThemeData theme) {
return SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _selectedMedias.length,
itemBuilder: (context, index) {
return _buildMediaItem(theme, index);
},
),
);
}
Widget _buildMediaItem(ThemeData theme, int index) {
final media = _selectedMedias[index];
final isVideo = media.path.toLowerCase().endsWith('.mp4') ||
media.path.toLowerCase().endsWith('.mov');
return Padding(
padding: const EdgeInsets.only(right: DesignSystem.spacingSm),
child: Stack(
children: [
// Image/Vidéo
ClipRRect(
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
child: Container(
width: 100,
height: 100,
color: theme.colorScheme.surfaceVariant,
child: isVideo
? Center(
child: Icon(
Icons.videocam_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant,
),
)
: Image.file(
File(media.path),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.broken_image_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant,
),
);
},
),
),
),
// Bouton de suppression
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => _removeMedia(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close_rounded,
size: 16,
color: Colors.white,
),
),
),
),
// Indicateur vidéo
if (isVideo)
Positioned(
bottom: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.play_circle_outline,
size: 12,
color: Colors.white,
),
],
),
),
),
],
),
);
}
Future<void> _pickFromGallery() async {
try {
final images = await _picker.pickMultiImage();
if (images.isNotEmpty) {
_addMedias(images);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la sélection: $e');
}
}
Future<void> _pickFromCamera() async {
try {
final image = await _picker.pickImage(source: ImageSource.camera);
if (image != null) {
_addMedias([image]);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la capture: $e');
}
}
Future<void> _pickVideo() async {
try {
final video = await _picker.pickVideo(source: ImageSource.gallery);
if (video != null) {
_addMedias([video]);
}
} catch (e) {
debugPrint('[MediaPicker] Erreur lors de la sélection vidéo: $e');
}
}
void _addMedias(List<XFile> medias) {
final availableSlots = widget.maxMedias - _selectedMedias.length;
final mediasToAdd = medias.take(availableSlots).toList();
setState(() {
_selectedMedias.addAll(mediasToAdd);
});
widget.onMediasChanged(_selectedMedias);
}
void _removeMedia(int index) {
setState(() {
_selectedMedias.removeAt(index);
});
widget.onMediasChanged(_selectedMedias);
}
}

View File

@@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../fullscreen_image_viewer.dart';
import 'fullscreen_video_player.dart';
/// Type de média dans un post.
enum MediaType { image, video }
/// Modèle de média pour un post.
class PostMedia {
const PostMedia({
required this.url,
required this.type,
this.thumbnailUrl,
this.duration,
});
final String url;
final MediaType type;
final String? thumbnailUrl;
final Duration? duration;
}
/// Widget d'affichage de médias dans un post social.
///
/// Supporte les images et vidéos avec différentes dispositions.
class PostMediaViewer extends StatelessWidget {
const PostMediaViewer({
required this.medias,
required this.postId,
this.onTap,
super.key,
});
final List<PostMedia> medias;
final String postId;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (medias.isEmpty) return const SizedBox.shrink();
if (medias.length == 1) {
return _buildSingleMedia(context, medias[0], 0);
} else if (medias.length == 2) {
return _buildDoubleMedia(context);
} else if (medias.length == 3) {
return _buildTripleMedia(context);
} else {
return _buildMultipleMedia(context);
}
}
/// Affiche un seul média
Widget _buildSingleMedia(BuildContext context, PostMedia media, int index) {
return GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: 1.0,
child: _MediaItem(
media: media,
postId: postId,
index: index,
),
),
);
}
/// Affiche deux médias côte à côte
Widget _buildDoubleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(width: 2),
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
],
),
);
}
/// Affiche trois médias (1 grand + 2 petits)
Widget _buildTripleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
flex: 2,
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(width: 2),
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
const SizedBox(height: 2),
Expanded(
child: _MediaItem(
media: medias[2],
postId: postId,
index: 2,
),
),
],
),
),
],
),
);
}
/// Affiche 4+ médias (grille 2x2 avec compteur)
Widget _buildMultipleMedia(BuildContext context) {
return SizedBox(
height: 300,
child: Row(
children: [
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[0],
postId: postId,
index: 0,
),
),
const SizedBox(height: 2),
Expanded(
child: _MediaItem(
media: medias[2],
postId: postId,
index: 2,
),
),
],
),
),
const SizedBox(width: 2),
Expanded(
child: Column(
children: [
Expanded(
child: _MediaItem(
media: medias[1],
postId: postId,
index: 1,
),
),
const SizedBox(height: 2),
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
_MediaItem(
media: medias[3],
postId: postId,
index: 3,
),
if (medias.length > 4)
Container(
color: Colors.black.withOpacity(0.6),
child: Center(
child: Text(
'+${medias.length - 4}',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
],
),
);
}
}
/// Widget d'affichage d'un média individuel.
class _MediaItem extends StatelessWidget {
const _MediaItem({
required this.media,
required this.postId,
required this.index,
});
final PostMedia media;
final String postId;
final int index;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Hero(
tag: 'post_media_${postId}_$index',
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
child: media.type == MediaType.image
? _buildImage(context, theme)
: _buildVideo(context, theme),
),
);
}
Widget _buildImage(BuildContext context, ThemeData theme) {
return GestureDetector(
onTap: () => _openFullscreen(context),
child: Image.network(
media.url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
strokeWidth: 2,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.broken_image_rounded,
size: 32,
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.5),
),
);
},
),
);
}
Widget _buildVideo(BuildContext context, ThemeData theme) {
return Stack(
fit: StackFit.expand,
children: [
// Thumbnail ou image de preview
if (media.thumbnailUrl != null)
Image.network(
media.thumbnailUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: theme.colorScheme.surfaceVariant,
);
},
)
else
Container(
color: theme.colorScheme.surfaceVariant,
),
// Overlay de play
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 32,
),
),
),
// Durée de la vidéo
if (media.duration != null)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
_formatDuration(media.duration!),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.2,
),
),
),
),
],
);
}
void _openFullscreen(BuildContext context) {
if (media.type == MediaType.image) {
Navigator.push<void>(
context,
PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.black,
pageBuilder: (context, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FullscreenImageViewer(
imageUrl: media.url,
heroTag: 'post_media_${postId}_$index',
title: '',
),
);
},
),
);
} else {
// Ouvrir le lecteur vidéo en plein écran
FullscreenVideoPlayer.show(
context: context,
videoUrl: media.url,
heroTag: 'post_media_${postId}_$index',
title: '',
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../animated_widgets.dart';
/// Bouton d'action tout petit et réutilisable pour les posts sociaux.
///
/// Design compact et uniforme pour les actions (like, comment, share, etc.)
class SocialActionButton extends StatelessWidget {
const SocialActionButton({
required this.icon,
required this.onTap,
this.color,
this.size = 22,
this.padding,
this.tooltip,
super.key,
});
final IconData icon;
final VoidCallback onTap;
final Color? color;
final double size;
final EdgeInsets? padding;
final String? tooltip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor =
color ?? theme.colorScheme.onSurface.withOpacity(0.7);
final button = AnimatedScaleButton(
onTap: onTap,
scaleFactor: 0.85,
child: Padding(
padding: padding ??
const EdgeInsets.all(DesignSystem.spacingSm),
child: Icon(
icon,
size: size,
color: effectiveColor,
),
),
);
if (tooltip != null) {
return Tooltip(
message: tooltip!,
child: button,
);
}
return button;
}
}
/// Bouton d'action avec compteur pour les posts sociaux.
class SocialActionButtonWithCount extends StatelessWidget {
const SocialActionButtonWithCount({
required this.icon,
required this.count,
required this.onTap,
this.color,
this.activeColor,
this.isActive = false,
this.size = 22,
this.showCount = true,
super.key,
});
final IconData icon;
final int count;
final VoidCallback onTap;
final Color? color;
final Color? activeColor;
final bool isActive;
final double size;
final bool showCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = isActive
? (activeColor ?? theme.colorScheme.primary)
: (color ?? theme.colorScheme.onSurface.withOpacity(0.7));
return AnimatedScaleButton(
onTap: onTap,
scaleFactor: 0.85,
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacingSm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: size,
color: effectiveColor,
),
if (showCount && count > 0) ...[
const SizedBox(width: 4),
Text(
_formatCount(count),
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: effectiveColor,
letterSpacing: -0.2,
),
),
],
],
),
),
);
}
String _formatCount(int count) {
if (count >= 1000000) {
final value = count / 1000000;
return value % 1 == 0 ? '${value.toInt()}M' : '${value.toStringAsFixed(1)}M';
} else if (count >= 1000) {
final value = count / 1000;
return value % 1 == 0 ? '${value.toInt()}K' : '${value.toStringAsFixed(1)}K';
}
return count.toString();
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
/// Badge réutilisable pour les posts sociaux.
///
/// Design compact et uniforme pour différents types de badges.
class SocialBadge extends StatelessWidget {
const SocialBadge({
required this.label,
this.icon,
this.color,
this.backgroundColor,
this.fontSize = 11,
this.padding,
super.key,
});
final String label;
final IconData? icon;
final Color? color;
final Color? backgroundColor;
final double fontSize;
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = color ?? theme.colorScheme.onPrimaryContainer;
final effectiveBackgroundColor =
backgroundColor ?? theme.colorScheme.primaryContainer;
return Container(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: effectiveBackgroundColor,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: fontSize + 2,
color: effectiveColor,
),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: effectiveColor,
letterSpacing: -0.1,
height: 1.2,
),
),
],
),
);
}
}
/// Badge vérifié pour les utilisateurs vérifiés.
class VerifiedBadge extends StatelessWidget {
const VerifiedBadge({
this.size = 16,
super.key,
});
final double size;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Tooltip(
message: 'Compte vérifié',
child: Icon(
Icons.verified,
size: size,
color: theme.colorScheme.primary,
),
);
}
}
/// Badge de catégorie pour les posts.
class CategoryBadge extends StatelessWidget {
const CategoryBadge({
required this.category,
this.icon,
super.key,
});
final String category;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SocialBadge(
label: category,
icon: icon,
backgroundColor: theme.colorScheme.secondaryContainer,
color: theme.colorScheme.onSecondaryContainer,
fontSize: 10,
);
}
}
/// Badge de statut pour les posts (nouveau, tendance, etc.).
class StatusBadge extends StatelessWidget {
const StatusBadge({
required this.status,
this.icon,
super.key,
});
final String status;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Color backgroundColor;
Color color;
switch (status.toLowerCase()) {
case 'nouveau':
case 'new':
backgroundColor = theme.colorScheme.primaryContainer;
color = theme.colorScheme.onPrimaryContainer;
break;
case 'tendance':
case 'trending':
backgroundColor = theme.colorScheme.errorContainer;
color = theme.colorScheme.onErrorContainer;
break;
case 'populaire':
case 'popular':
backgroundColor = theme.colorScheme.tertiaryContainer;
color = theme.colorScheme.onTertiaryContainer;
break;
default:
backgroundColor = theme.colorScheme.surfaceVariant;
color = theme.colorScheme.onSurfaceVariant;
}
return SocialBadge(
label: status,
icon: icon,
backgroundColor: backgroundColor,
color: color,
fontSize: 10,
);
}
}
/// Badge de nombre de médias (images/vidéos).
class MediaCountBadge extends StatelessWidget {
const MediaCountBadge({
required this.count,
this.isVideo = false,
super.key,
});
final int count;
final bool isVideo;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isVideo ? Icons.play_circle_outline : Icons.image_outlined,
size: 12,
color: Colors.white,
),
const SizedBox(width: 3),
Text(
count.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.2,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,585 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import '../animated_widgets.dart';
import 'post_media_viewer.dart';
import 'social_action_button.dart';
import 'social_badge.dart';
/// Card modulaire et réutilisable pour afficher un post social.
///
/// Utilise des composants atomiques pour une meilleure réutilisabilité.
class SocialCardRefactored extends StatefulWidget {
const SocialCardRefactored({
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
required this.onDeletePost,
required this.onEditPost,
this.showVerifiedBadge = false,
this.showCategory = false,
this.category,
super.key,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
final VoidCallback onDeletePost;
final VoidCallback onEditPost;
final bool showVerifiedBadge;
final bool showCategory;
final String? category;
@override
State<SocialCardRefactored> createState() => _SocialCardRefactoredState();
}
class _SocialCardRefactoredState extends State<SocialCardRefactored> {
bool _showFullContent = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedCard(
margin: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
borderRadius: DesignSystem.borderRadiusMd,
elevation: 0.5,
hoverElevation: 1.5,
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
_PostHeader(
post: widget.post,
showVerifiedBadge: widget.showVerifiedBadge,
showCategory: widget.showCategory,
category: widget.category,
onEdit: widget.onEditPost,
onDelete: widget.onDeletePost,
),
// Médias (si présents)
if (widget.post.imageUrl != null &&
widget.post.imageUrl!.isNotEmpty)
PostMediaViewer(
medias: [
PostMedia(
url: widget.post.imageUrl!,
type: MediaType.image,
),
],
postId: widget.post.id,
onTap: widget.onLike,
),
// Contenu et interactions
Padding(
padding: const EdgeInsets.all(DesignSystem.spacingLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actions rapides
_PostActions(
post: widget.post,
onLike: widget.onLike,
onComment: widget.onComment,
onShare: widget.onShare,
),
const SizedBox(height: DesignSystem.spacingMd),
// Statistiques
if (widget.post.likesCount > 0)
_PostStats(likesCount: widget.post.likesCount),
// Contenu du post
_PostContent(
post: widget.post,
showFullContent: _showFullContent,
onToggleFullContent: () {
setState(() {
_showFullContent = !_showFullContent;
});
},
),
// Lien vers les commentaires
if (widget.post.commentsCount > 0)
_CommentsLink(
commentsCount: widget.post.commentsCount,
onTap: widget.onComment,
),
],
),
),
],
),
);
}
}
/// Header du post avec avatar, nom, timestamp et menu.
class _PostHeader extends StatelessWidget {
const _PostHeader({
required this.post,
required this.showVerifiedBadge,
required this.showCategory,
required this.category,
required this.onEdit,
required this.onDelete,
});
final SocialPost post;
final bool showVerifiedBadge;
final bool showCategory;
final String? category;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
DesignSystem.spacingLg,
DesignSystem.spacingMd,
DesignSystem.spacingSm,
DesignSystem.spacingMd,
),
child: Row(
children: [
// Avatar
_UserAvatar(imageUrl: post.userProfileImageUrl),
const SizedBox(width: DesignSystem.spacingMd),
// Informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Nom
Flexible(
child: Text(
post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Badge vérifié
if (showVerifiedBadge) ...[
const SizedBox(width: 4),
const VerifiedBadge(size: 14),
],
// Catégorie
if (showCategory && category != null) ...[
const SizedBox(width: 6),
CategoryBadge(category: category!),
],
],
),
// Timestamp
const SizedBox(height: 2),
_PostTimestamp(timestamp: post.timestamp),
],
),
),
// Menu
_PostMenu(
onEdit: onEdit,
onDelete: onDelete,
),
],
),
);
}
}
/// Avatar utilisateur réutilisable.
class _UserAvatar extends StatelessWidget {
const _UserAvatar({
required this.imageUrl,
this.size = 40,
});
final String imageUrl;
final double size;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final radius = size / 2;
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withOpacity(0.3),
theme.colorScheme.secondary.withOpacity(0.3),
],
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(2),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.scaffoldBackgroundColor,
),
padding: const EdgeInsets.all(1.5),
child: CircleAvatar(
radius: radius - 3.5,
backgroundColor: theme.colorScheme.surfaceVariant,
backgroundImage:
imageUrl.isNotEmpty ? NetworkImage(imageUrl) : null,
child: imageUrl.isEmpty
? Icon(
Icons.person_rounded,
size: radius - 2,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
),
);
}
}
/// Timestamp du post.
class _PostTimestamp extends StatelessWidget {
const _PostTimestamp({required this.timestamp});
final DateTime timestamp;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
_formatTimestamp(timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 12,
height: 1.2,
),
);
}
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) return 'À l\'instant';
if (difference.inMinutes < 60) return 'Il y a ${difference.inMinutes}min';
if (difference.inHours < 24) return 'Il y a ${difference.inHours}h';
if (difference.inDays < 7) return 'Il y a ${difference.inDays}j';
if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return 'Il y a ${weeks}sem';
}
return '${timestamp.day}/${timestamp.month}/${timestamp.year}';
}
}
/// Menu d'options du post.
class _PostMenu extends StatelessWidget {
const _PostMenu({
required this.onEdit,
required this.onDelete,
});
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_horiz_rounded, size: 20),
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
break;
case 'delete':
onDelete();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 12),
Text('Modifier', style: TextStyle(fontSize: 14)),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline, size: 18),
SizedBox(width: 12),
Text('Supprimer', style: TextStyle(fontSize: 14)),
],
),
),
],
);
}
}
/// Actions rapides du post (like, comment, share, bookmark).
class _PostActions extends StatefulWidget {
const _PostActions({
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
@override
State<_PostActions> createState() => _PostActionsState();
}
class _PostActionsState extends State<_PostActions> {
bool _isBookmarked = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
// Like
SocialActionButton(
icon: widget.post.isLikedByCurrentUser
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
onTap: widget.onLike,
color: widget.post.isLikedByCurrentUser ? Colors.red : null,
tooltip: 'J\'aime',
),
const SizedBox(width: DesignSystem.spacingSm),
// Comment
SocialActionButton(
icon: Icons.chat_bubble_outline_rounded,
onTap: widget.onComment,
tooltip: 'Commenter',
),
const SizedBox(width: DesignSystem.spacingSm),
// Share
SocialActionButton(
icon: Icons.send_outlined,
onTap: widget.onShare,
tooltip: 'Partager',
),
const Spacer(),
// Bookmark
SocialActionButton(
icon: _isBookmarked
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
onTap: () {
setState(() {
_isBookmarked = !_isBookmarked;
});
},
color: _isBookmarked ? theme.colorScheme.primary : null,
tooltip: 'Enregistrer',
),
],
);
}
}
/// Statistiques du post (nombre de likes).
class _PostStats extends StatelessWidget {
const _PostStats({required this.likesCount});
final int likesCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: DesignSystem.spacingMd),
child: Text(
likesCount == 1 ? '1 j\'aime' : '$likesCount j\'aime',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: -0.1,
),
),
);
}
}
/// Contenu du post avec support des hashtags et mentions.
class _PostContent extends StatelessWidget {
const _PostContent({
required this.post,
required this.showFullContent,
required this.onToggleFullContent,
});
final SocialPost post;
final bool showFullContent;
final VoidCallback onToggleFullContent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = post.content;
final shouldTruncate = content.length > 150 && !showFullContent;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.4,
letterSpacing: -0.1,
),
children: [
TextSpan(
text: '${post.authorFullName} ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
..._buildEnrichedContent(
shouldTruncate ? '${content.substring(0, 150)}...' : content,
theme,
),
],
),
),
if (content.length > 150)
GestureDetector(
onTap: onToggleFullContent,
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
showFullContent ? 'Voir moins' : 'Voir plus',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
List<TextSpan> _buildEnrichedContent(String content, ThemeData theme) {
final spans = <TextSpan>[];
final words = content.split(' ');
for (var i = 0; i < words.length; i++) {
final word = words[i];
if (word.startsWith('#') || word.startsWith('@')) {
spans.add(
TextSpan(
text: word,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
recognizer: TapGestureRecognizer()
..onTap = () {
debugPrint('Cliqué sur: $word');
},
),
);
} else {
spans.add(TextSpan(text: word));
}
if (i < words.length - 1) {
spans.add(const TextSpan(text: ' '));
}
}
return spans;
}
}
/// Lien vers les commentaires.
class _CommentsLink extends StatelessWidget {
const _CommentsLink({
required this.commentsCount,
required this.onTap,
});
final int commentsCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: DesignSystem.spacingMd),
child: Text(
commentsCount == 1
? 'Voir le commentaire'
: 'Voir les $commentsCount commentaires',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 13,
),
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
/// Widgets réutilisables pour les posts sociaux.
///
/// Ce fichier exporte tous les widgets atomiques et composants
/// pour la création et l'affichage de posts sociaux.
// Widgets atomiques
export 'social_action_button.dart';
export 'social_badge.dart';
// Widgets de médias
export 'media_picker.dart';
export 'post_media_viewer.dart';
// Dialogues et composants complexes
export 'create_post_dialog.dart';
export 'edit_post_dialog.dart';
export 'social_card_refactored.dart';
// Lecteurs de médias
export 'fullscreen_video_player.dart';

View File

@@ -1,26 +1,24 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
class BadgeWidget extends StatelessWidget {
final String badge;
final IconData? icon; // Optionnel : ajouter une icône au badge
class BadgeWidget extends StatelessWidget { // Optionnel : ajouter une icône au badge
const BadgeWidget({
Key? key,
required this.badge,
required this.badge, super.key,
this.icon,
}) : super(key: key);
});
final String badge;
final IconData? icon;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.accentColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(12.0),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.accentColor,
width: 1.0,
),
),
child: Row(
@@ -30,7 +28,7 @@ class BadgeWidget extends StatelessWidget {
Icon(
icon,
color: AppColors.accentColor,
size: 16.0,
size: 16,
),
const SizedBox(width: 5),
],

View File

@@ -1,119 +1,394 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
import '../../../data/models/social_post_model.dart';
import 'social_badge_widget.dart'; // Import du widget BadgeWidget
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/constants/design_system.dart';
import '../../../domain/entities/social_post.dart';
import '../widgets/custom_snackbar.dart';
/// Widget d'en-tête pour les posts sociaux avec design moderne.
///
/// Affiche l'avatar, le nom de l'utilisateur, le timestamp et les options.
class SocialHeaderWidget extends StatelessWidget {
final SocialPost post;
final VoidCallback onEditPost;
final VoidCallback onClosePost; // Ajout du callback pour la fermeture du post
final GlobalKey menuKey;
final BuildContext menuContext;
const SocialHeaderWidget({
Key? key,
required this.post,
required this.onEditPost,
required this.onClosePost, // Initialisation du callback de fermeture
required this.onClosePost,
required this.menuKey,
required this.menuContext,
}) : super(key: key);
this.showVerifiedBadge = false,
super.key,
});
final SocialPost post;
final VoidCallback onEditPost;
final VoidCallback onClosePost;
final GlobalKey menuKey;
final BuildContext menuContext;
final bool showVerifiedBadge;
@override
Widget build(BuildContext context) {
return Stack(
final theme = Theme.of(context);
return Row(
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.grey.shade800,
backgroundImage: post.userImage.isNotEmpty
? AssetImage(post.userImage)
: const AssetImage('lib/assets/images/placeholder.png'),
radius: 22,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Avatar avec bordure gradient (style Instagram)
_buildAvatar(theme),
const SizedBox(width: DesignSystem.spacingMd),
// Informations utilisateur
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Nom avec badge de vérification
Row(
children: [
Text(
post.userName,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
post.authorFullName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
children: post.badges
.map((badge) => BadgeWidget(badge: badge))
.toList(),
),
if (showVerifiedBadge) ...[
const SizedBox(width: 4),
Icon(
Icons.verified,
size: 16,
color: theme.colorScheme.primary,
),
],
],
),
),
],
),
// Ajout des boutons dans le coin supérieur droit
Positioned(
top: 0,
right: 0,
child: Row(
mainAxisSize: MainAxisSize.min, // Réduit la taille du Row au minimum
children: [
IconButton(
key: menuKey,
icon: const Icon(Icons.more_vert, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: () {
_showOptionsMenu(menuContext, menuKey);
},
),
const SizedBox(width: 4), // Espacement entre les boutons
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 20),
splashRadius: 20,
onPressed: onClosePost, // Appel du callback de fermeture
// Timestamp
const SizedBox(height: 2),
Text(
_formatTimestamp(post.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontSize: 12,
height: 1.2,
),
),
],
),
),
// Menu options
IconButton(
key: menuKey,
icon: const Icon(Icons.more_horiz_rounded),
iconSize: 22,
onPressed: () => _showOptionsMenu(menuContext),
color: theme.colorScheme.onSurface.withOpacity(0.6),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
);
}
void _showOptionsMenu(BuildContext context, GlobalKey menuKey) {
showModalBottomSheet(
/// Construit l'avatar avec bordure dégradée
Widget _buildAvatar(ThemeData theme) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withOpacity(0.3),
theme.colorScheme.secondary.withOpacity(0.3),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(2),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.scaffoldBackgroundColor,
),
padding: const EdgeInsets.all(2),
child: CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.surfaceVariant,
backgroundImage: _getProfileImage(),
child: _getProfileImage() == null
? Icon(
Icons.person_rounded,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
)
: null,
),
),
);
}
/// Récupère l'image de profil de l'utilisateur
ImageProvider? _getProfileImage() {
if (post.userProfileImageUrl.isEmpty) {
return null;
}
if (post.userProfileImageUrl.startsWith('http://') ||
post.userProfileImageUrl.startsWith('https://')) {
return NetworkImage(post.userProfileImageUrl);
}
return AssetImage(post.userProfileImageUrl);
}
/// Formate le timestamp de manière relative
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) {
return 'À l\'instant';
} else if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes} min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays}j';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return 'Il y a ${weeks}sem';
} else {
return DateFormat('d MMM', 'fr_FR').format(timestamp);
}
}
/// Affiche le menu des options
void _showOptionsMenu(BuildContext context) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
final theme = Theme.of(context);
return Container(
color: AppColors.backgroundColor,
child: Wrap(
children: <Widget>[
ListTile(
leading: Icon(Icons.edit, color: AppColors.iconPrimary),
title: const Text('Modifier'),
onTap: () {
Navigator.of(context).pop();
onEditPost();
},
),
ListTile(
leading: Icon(Icons.delete, color: AppColors.iconPrimary),
title: const Text('Supprimer'),
onTap: () {
Navigator.of(context).pop();
},
),
],
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(DesignSystem.radiusLg),
),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(
top: DesignSystem.spacingMd,
bottom: DesignSystem.spacingSm,
),
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
// Options
_buildMenuOption(
context,
theme,
Icons.edit_outlined,
'Modifier',
onEditPost,
),
_buildMenuOption(
context,
theme,
Icons.link_rounded,
'Copier le lien',
() {
_copyPostLink(context);
},
),
_buildMenuOption(
context,
theme,
Icons.share_outlined,
'Partager',
() {
_sharePost(context);
},
),
Divider(
height: 1,
thickness: 1,
color: theme.dividerColor.withOpacity(0.5),
),
_buildMenuOption(
context,
theme,
Icons.report_outlined,
'Signaler',
() {
_reportPost(context);
},
isDestructive: false,
textColor: theme.colorScheme.error,
),
const SizedBox(height: DesignSystem.spacingSm),
],
),
),
);
},
);
}
/// Construit une option du menu
Widget _buildMenuOption(
BuildContext context,
ThemeData theme,
IconData icon,
String title,
VoidCallback onTap, {
bool isDestructive = false,
Color? textColor,
}) {
final color = textColor ?? (isDestructive ? theme.colorScheme.error : null);
return ListTile(
leading: Icon(
icon,
color: color,
size: 22,
),
title: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontSize: 14,
),
),
onTap: () {
Navigator.of(context).pop();
onTap();
},
contentPadding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingXs,
),
);
}
/// Copie le lien du post dans le presse-papiers
void _copyPostLink(BuildContext context) {
final postUrl = 'https://afterwork.app/post/${post.id}';
Clipboard.setData(ClipboardData(text: postUrl));
context.showSuccess('Lien copié dans le presse-papiers');
}
/// Partage le post via la fonctionnalité native de partage
Future<void> _sharePost(BuildContext context) async {
try {
final postUrl = 'https://afterwork.app/post/${post.id}';
final shareText = '${post.content}\n\n$postUrl';
await Share.share(
shareText,
subject: 'Publication AfterWork',
);
} catch (e) {
if (context.mounted) {
context.showError('Erreur lors du partage');
}
}
}
/// Affiche le dialog de signalement
void _reportPost(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler ce post'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pourquoi signalez-vous ce post ?',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: DesignSystem.spacingMd),
_buildReportOption(context, 'Contenu inapproprié'),
_buildReportOption(context, 'Spam ou arnaque'),
_buildReportOption(context, 'Harcèlement'),
_buildReportOption(context, 'Fausses informations'),
_buildReportOption(context, 'Autre'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
);
}
/// Construit une option de signalement
Widget _buildReportOption(BuildContext context, String reason) {
return InkWell(
onTap: () {
Navigator.of(context).pop();
_submitReport(context, reason);
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: DesignSystem.spacingSm,
horizontal: DesignSystem.spacingXs,
),
child: Row(
children: [
Icon(
Icons.report_outlined,
size: 20,
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
),
const SizedBox(width: DesignSystem.spacingMd),
Text(
reason,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
/// Soumet le signalement
Future<void> _submitReport(BuildContext context, String reason) async {
// Simuler l'envoi du signalement
// Dans une vraie application, cela ferait un appel API
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted) {
context.showSuccess('Signalement envoyé. Merci pour votre contribution.');
}
}
}

View File

@@ -1,47 +1,215 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
import '../../../data/models/social_post_model.dart';
class SocialInteractionRow extends StatelessWidget {
import '../../core/constants/design_system.dart';
import '../../domain/entities/social_post.dart';
import 'animated_widgets.dart';
/// Widget de barre d'interactions pour les posts sociaux.
///
/// Affiche les boutons Like, Comment, Share et Bookmark avec animations.
class SocialInteractionRow extends StatefulWidget {
const SocialInteractionRow({
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
super.key,
});
final SocialPost post;
final VoidCallback onLike;
final VoidCallback onComment;
final VoidCallback onShare;
const SocialInteractionRow({
Key? key,
required this.post,
required this.onLike,
required this.onComment,
required this.onShare,
}) : super(key: key);
@override
State<SocialInteractionRow> createState() => _SocialInteractionRowState();
}
class _SocialInteractionRowState extends State<SocialInteractionRow>
with SingleTickerProviderStateMixin {
late AnimationController _likeController;
bool _isBookmarked = false;
@override
void initState() {
super.initState();
_likeController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
if (widget.post.isLikedByCurrentUser) {
_likeController.value = 1.0;
}
}
@override
void didUpdateWidget(SocialInteractionRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.post.isLikedByCurrentUser != oldWidget.post.isLikedByCurrentUser) {
if (widget.post.isLikedByCurrentUser) {
_likeController.forward();
} else {
_likeController.reverse();
}
}
}
@override
void dispose() {
_likeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: _buildIconButton(Icons.thumb_up_alt_outlined, 'Jaime', post.likes, onLike),
),
Expanded(
child: _buildIconButton(Icons.comment_outlined, 'Commentaires', post.comments, onComment),
),
Expanded(
child: _buildIconButton(Icons.share_outlined, 'Partages', post.shares, onShare),
),
_buildLikeButton(theme),
const SizedBox(width: DesignSystem.spacingLg),
_buildCommentButton(theme),
const SizedBox(width: DesignSystem.spacingLg),
_buildShareButton(theme),
const Spacer(),
_buildBookmarkButton(theme),
],
);
}
Widget _buildIconButton(IconData icon, String label, int count, VoidCallback onPressed) {
return TextButton.icon(
onPressed: onPressed,
icon: Icon(icon, color: AppColors.accentColor, size: 18),
label: Text(
'$label ($count)',
style: const TextStyle(color: Colors.white70, fontSize: 12),
overflow: TextOverflow.ellipsis,
/// Construit le bouton Like avec animation
Widget _buildLikeButton(ThemeData theme) {
final isLiked = widget.post.isLikedByCurrentUser;
return AnimatedScaleButton(
onTap: () {
if (isLiked) {
_likeController.reverse();
} else {
_likeController.forward();
}
widget.onLike();
},
scaleFactor: 0.8,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingSm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _likeController,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
// Cercle d'effet de like
if (_likeController.value > 0)
Container(
width: 26 + (8 * _likeController.value),
height: 26 + (8 * _likeController.value),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.withOpacity(
0.2 * (1 - _likeController.value),
),
),
),
// Icône de coeur avec animation
Transform.scale(
scale: 1 + (_likeController.value * 0.2),
child: Icon(
isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
size: 26,
color: isLiked
? Colors.red
: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
);
},
),
],
),
),
);
}
/// Construit le bouton Commentaire
Widget _buildCommentButton(ThemeData theme) {
return AnimatedScaleButton(
onTap: widget.onComment,
scaleFactor: 0.85,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingSm,
),
child: Icon(
Icons.chat_bubble_outline_rounded,
size: 26,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
);
}
/// Construit le bouton Partage
Widget _buildShareButton(ThemeData theme) {
return AnimatedScaleButton(
onTap: widget.onShare,
scaleFactor: 0.85,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingSm,
),
child: Icon(
Icons.send_outlined,
size: 26,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
);
}
/// Construit le bouton Bookmark
Widget _buildBookmarkButton(ThemeData theme) {
return AnimatedScaleButton(
onTap: () {
setState(() {
_isBookmarked = !_isBookmarked;
});
},
scaleFactor: 0.85,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingSm,
),
child: AnimatedSwitcher(
duration: DesignSystem.durationFast,
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: Icon(
_isBookmarked
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
key: ValueKey<bool>(_isBookmarked),
size: 26,
color: _isBookmarked
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
);
}

View File

@@ -4,24 +4,21 @@ import '../../../../core/constants/colors.dart';
/// [StatTile] affiche une statistique utilisateur avec une icône, un label et une valeur.
/// Ce composant inclut des animations et une traçabilité des interactions.
class StatTile extends StatelessWidget {
const StatTile({
required this.icon, required this.label, required this.value, super.key,
});
final IconData icon;
final String label;
final String value;
const StatTile({
Key? key,
required this.icon,
required this.label,
required this.value,
}) : super(key: key);
@override
Widget build(BuildContext context) {
debugPrint("[LOG] Initialisation de StatTile pour la statistique : $label");
debugPrint('[LOG] Initialisation de StatTile pour la statistique : $label');
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 500),
tween: Tween<double>(begin: 0.9, end: 1.0),
tween: Tween<double>(begin: 0.9, end: 1),
curve: Curves.easeOutBack,
builder: (context, scale, child) {
return Transform.scale(

View File

@@ -1,24 +1,20 @@
import 'package:flutter/material.dart';
import 'package:afterwork/presentation/widgets/story_video_player.dart';
import '../../../core/utils/calculate_time_ago.dart';
import 'animated_action_button.dart';
import 'story_video_player.dart';
class StoryDetail extends StatefulWidget {
const StoryDetail({
required this.username, required this.publicationDate, required this.mediaUrl, required this.userImage, required this.isVideo, super.key,
});
final String username;
final DateTime publicationDate;
final String mediaUrl;
final String userImage;
final bool isVideo;
const StoryDetail({
super.key,
required this.username,
required this.publicationDate,
required this.mediaUrl,
required this.userImage,
required this.isVideo,
});
@override
StoryDetailState createState() => StoryDetailState();
}

View File

@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import '../../core/constants/colors.dart';
import '../../core/utils/calculate_time_ago.dart';
import 'story_detail.dart';
import 'create_story.dart';
import 'story_detail.dart';
/// La classe StorySection représente la section des stories dans l'interface.
/// Elle affiche une liste horizontale de stories et permet à l'utilisateur d'ajouter une nouvelle story.
/// Les logs sont utilisés pour tracer chaque action réalisée dans l'interface.
class StorySection extends StatelessWidget {
final Size size;
final Logger logger = Logger(); // Logger pour tracer les événements et actions
class StorySection extends StatelessWidget { // Logger pour tracer les événements et actions
StorySection({required this.size, super.key});
final Size size;
final Logger logger = Logger();
@override
Widget build(BuildContext context) {
@@ -29,7 +30,7 @@ class StorySection extends StatelessWidget {
itemBuilder: (context, index) {
if (index == 0) return _buildAddStoryCard(context);
DateTime publicationDate = DateTime.now().subtract(Duration(hours: (index - 1) * 6));
final DateTime publicationDate = DateTime.now().subtract(Duration(hours: (index - 1) * 6));
logger.i('Affichage de la story $index avec la date $publicationDate');
return GestureDetector(
@@ -62,7 +63,6 @@ class StorySection extends StatelessWidget {
/// Construit une carte de story à partir de l'index et de la date de publication.
Widget _buildStoryCard(int index, DateTime publicationDate) {
return Column( // Utilisation de Column sans Expanded pour éviter les erreurs
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: size.width / 4.5,
@@ -157,7 +157,6 @@ class StorySection extends StatelessWidget {
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: size.width / 4.5,

View File

@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; // Pour la lecture des vidéos
class StoryVideoPlayer extends StatefulWidget {
final String mediaUrl;
const StoryVideoPlayer({super.key, required this.mediaUrl});
const StoryVideoPlayer({required this.mediaUrl, super.key});
final String mediaUrl;
@override
StoryVideoPlayerState createState() => StoryVideoPlayerState(); // Classe publique
@@ -20,7 +20,7 @@ class StoryVideoPlayerState extends State<StoryVideoPlayer> {
_initializeVideoPlayer();
}
void _initializeVideoPlayer() async {
Future<void> _initializeVideoPlayer() async {
_videoPlayerController = VideoPlayerController.networkUrl(Uri.parse(widget.mediaUrl));
try {

View File

@@ -1,57 +1,190 @@
import 'package:flutter/material.dart';
/// Bouton de soumission avec un gradient visuel et des ombres
/// Utilisé pour l'envoi d'un formulaire d'événement
class SubmitButton extends StatelessWidget {
/// Fonction à exécuter lors de l'appui sur le bouton
/// Bouton de soumission avec gradé visuel et animations.
///
/// Ce widget fournit un bouton de soumission moderne avec:
/// - Dégradé de couleurs adaptatif au thème
/// - Ombres et élévations
/// - Support des états de chargement
/// - Animations fluides
///
/// **Usage:**
/// ```dart
/// SubmitButton(
/// text: 'Créer l\'événement',
/// onPressed: () {
/// // Action de soumission
/// },
/// isLoading: false,
/// )
/// ```
class SubmitButton extends StatefulWidget {
/// Crée un nouveau [SubmitButton].
///
/// [text] Le texte à afficher sur le bouton
/// [onPressed] La fonction à exécuter lors du clic
/// [isLoading] Si true, affiche un indicateur de chargement
/// [isEnabled] Si false, désactive le bouton
/// [icon] Une icône optionnelle à afficher avant le texte
const SubmitButton({
required this.text,
required this.onPressed,
super.key,
this.isLoading = false,
this.isEnabled = true,
this.icon,
});
/// Le texte à afficher sur le bouton
final String text;
/// La fonction à exécuter lors du clic
final VoidCallback onPressed;
const SubmitButton({Key? key, required this.onPressed}) : super(key: key);
/// Si true, affiche un indicateur de chargement
final bool isLoading;
/// Si false, désactive le bouton
final bool isEnabled;
/// Une icône optionnelle à afficher avant le texte
final IconData? icon;
@override
State<SubmitButton> createState() => _SubmitButtonState();
}
class _SubmitButtonState extends State<SubmitButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_animationController.forward();
}
void _handleTapUp(TapUpDetails details) {
_animationController.reverse();
}
void _handleTapCancel() {
_animationController.reverse();
}
@override
Widget build(BuildContext context) {
return Container(
// Décoration du bouton avec un dégradé de couleurs et une ombre
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF1DBF73), // Dégradé vert clair
Color(0xFF11998E), // Dégradé vert foncé
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 8,
offset: const Offset(2, 4), // Position de l'ombre
final theme = Theme.of(context);
final isDisabled = !widget.isEnabled || widget.isLoading;
return ScaleTransition(
scale: _scaleAnimation,
child: GestureDetector(
onTapDown: isDisabled ? null : _handleTapDown,
onTapUp: isDisabled ? null : _handleTapUp,
onTapCancel: isDisabled ? null : _handleTapCancel,
child: Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDisabled
? [
theme.colorScheme.surface,
theme.colorScheme.surface,
]
: [
theme.colorScheme.tertiary,
theme.colorScheme.secondary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: isDisabled
? null
: [
BoxShadow(
color: theme.colorScheme.tertiary.withOpacity(0.3),
spreadRadius: 2,
blurRadius: 8,
offset: const Offset(0, 4),
),
],
borderRadius: BorderRadius.circular(16),
),
],
borderRadius: BorderRadius.circular(8.0),
),
child: ElevatedButton(
onPressed: onPressed, // Appel de la fonction passée en paramètre
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent, // Fond transparent pour voir le dégradé
shadowColor: Colors.transparent, // Suppression de l'ombre par défaut
padding: const EdgeInsets.symmetric(vertical: 14.0),
minimumSize: const Size(double.infinity, 50), // Taille du bouton
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
child: const Text(
'Créer l\'événement',
style: TextStyle(
color: Colors.white, // Couleur du texte
fontSize: 16, // Taille du texte
fontWeight: FontWeight.bold, // Texte en gras
letterSpacing: 1.2, // Espacement entre les lettres
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: isDisabled ? null : widget.onPressed,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: widget.isLoading
? Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.onPrimary,
),
),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: isDisabled
? theme.colorScheme.onSurface.withOpacity(0.38)
: theme.colorScheme.onPrimary,
size: 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: theme.textTheme.titleMedium?.copyWith(
color: isDisabled
? theme.colorScheme.onSurface.withOpacity(0.38)
: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -1,29 +1,57 @@
import 'package:flutter/material.dart';
/// Arrière-plan pour les actions de swipe avec design moderne.
///
/// Ce widget affiche un arrière-plan coloré avec une icône et un label
/// lors d'une action de swipe sur une carte d'événement.
///
/// **Usage:**
/// ```dart
/// SwipeBackground(
/// color: Colors.red,
/// icon: Icons.lock,
/// label: 'Fermer',
/// )
/// ```
class SwipeBackground extends StatelessWidget {
const SwipeBackground({
required this.color,
required this.icon,
required this.label,
super.key,
});
final Color color;
final IconData icon;
final String label;
const SwipeBackground({
Key? key,
required this.color,
required this.icon,
required this.label,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: color,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 10),
Text(label, style: const TextStyle(color: Colors.white)),
Icon(
icon,
color: Colors.white,
size: 24,
),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
);

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../../core/constants/design_system.dart';
/// Widget pour afficher un indicateur de frappe dans le chat.
class TypingIndicatorWidget extends StatefulWidget {
const TypingIndicatorWidget({super.key});
@override
State<TypingIndicatorWidget> createState() => _TypingIndicatorWidgetState();
}
class _TypingIndicatorWidgetState extends State<TypingIndicatorWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const SizedBox(width: DesignSystem.spacingSm),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
color: theme.brightness == Brightness.dark ? Colors.grey[800] : Colors.grey[200],
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDot(0),
const SizedBox(width: 4),
_buildDot(1),
const SizedBox(width: 4),
_buildDot(2),
],
),
),
],
);
}
Widget _buildDot(int index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final theme = Theme.of(context);
final delay = index * 0.2;
final value = (_controller.value - delay) % 1.0;
final opacity = (value < 0.5 ? value * 2 : (1 - value) * 2).clamp(0.3, 1.0);
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: (theme.brightness == Brightness.dark ? Colors.white : Colors.black87)
.withOpacity(opacity),
shape: BoxShape.circle,
),
);
},
);
}
}