Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/shared/mini_header_bar.dart';
import '../../shared/widgets/core_card.dart';
import '../widgets/shared/mini_metric_widget.dart';
import '../../shared/widgets/core_shimmer.dart';
import '../../shared/widgets/info_badge.dart';
import '../../shared/design_system/tokens/app_typography.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import '../../core/di/injection.dart';
import '../../features/dashboard/presentation/bloc/finance_bloc.dart';
import '../../features/dashboard/presentation/bloc/finance_event.dart';
import '../../features/dashboard/presentation/bloc/finance_state.dart';
/// UnionFlow Mobile - Onglet Finances (Mode DRY & Ultra-compact)
/// Évite les gros blocs de texte, privilégie les métriques denses.
class FinancePage extends StatelessWidget {
const FinancePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<FinanceBloc>()..add(LoadFinanceRequested()),
child: const _FinanceView(),
);
}
}
class _FinanceView extends StatelessWidget {
const _FinanceView();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const MiniHeaderBar(title: 'Finances'),
body: BlocBuilder<FinanceBloc, FinanceState>(
builder: (context, state) {
if (state is FinanceInitial || state is FinanceLoading) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: CoreShimmer(itemCount: 4),
);
}
if (state is FinanceError) {
return Center(
child: Text(
state.message,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
),
);
}
if (state is FinanceLoaded) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé Compact
CoreCard(
child: IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: MiniMetricWidget(
label: 'Payées',
value: '${state.summary.totalContributionsPaid} F',
valueColor: AppColors.success,
alignment: CrossAxisAlignment.center,
),
),
const VerticalDivider(color: AppColors.lightBorder),
Expanded(
child: MiniMetricWidget(
label: 'En attente',
value: '${state.summary.totalContributionsPending} F',
valueColor: AppColors.warning,
alignment: CrossAxisAlignment.center,
),
),
const VerticalDivider(color: AppColors.lightBorder),
Expanded(
child: MiniMetricWidget(
label: 'Épargne',
value: '${state.summary.epargneBalance} F',
alignment: CrossAxisAlignment.center,
),
),
],
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
'HISTORIQUE',
style: AppTypography.badgeText.copyWith(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
],
),
),
),
// Liste des transactions (Recycle CoreCard)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final tx = state.transactions[index];
final isPaid = tx.status == 'Payé';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CoreCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tx.title,
style: AppTypography.actionText,
),
Text(
tx.date,
style: AppTypography.subtitleSmall,
),
],
),
Row(
children: [
Text(
'${tx.amount} F',
style: AppTypography.actionText,
),
const SizedBox(width: 8),
InfoBadge(
text: tx.status,
backgroundColor: isPaid ? AppColors.success.withOpacity(0.1) : AppColors.warning.withOpacity(0.1),
textColor: isPaid ? AppColors.success : AppColors.warning,
),
],
),
],
),
),
);
},
childCount: state.transactions.length,
),
),
],
);
}
return const SizedBox.shrink();
},
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/shared/mini_header_bar.dart';
import '../../shared/widgets/core_card.dart';
import '../../shared/widgets/mini_avatar.dart';
import '../../shared/widgets/core_shimmer.dart';
import '../../shared/widgets/info_badge.dart';
import '../../shared/design_system/tokens/app_typography.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import '../../core/di/injection.dart';
import '../../features/explore/presentation/bloc/network_bloc.dart';
import '../../features/explore/presentation/bloc/network_event.dart';
import '../../features/explore/presentation/bloc/network_state.dart';
/// UnionFlow Mobile - Onglet Réseau/Découverte (Mode DRY)
/// Affiche les membres et organisations. Strict minimalisme.
class NetworkPage extends StatelessWidget {
const NetworkPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<NetworkBloc>()..add(LoadNetworkRequested()),
child: const _NetworkView(),
);
}
}
class _NetworkView extends StatefulWidget {
const _NetworkView();
@override
State<_NetworkView> createState() => _NetworkViewState();
}
class _NetworkViewState extends State<_NetworkView> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
context.read<NetworkBloc>().add(SearchNetworkRequested(query));
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: const MiniHeaderBar(title: 'Réseau'),
body: Column(
children: [
// Barre de recherche collante (Twitter Style)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
border: Border(
bottom: BorderSide(
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
width: 1,
),
),
),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
style: AppTypography.actionText,
decoration: InputDecoration(
hintText: 'Rechercher des membres, organisations...',
hintStyle: AppTypography.subtitleSmall,
prefixIcon: const Icon(Icons.search, size: 20, color: AppColors.textSecondaryLight),
filled: true,
fillColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
contentPadding: const EdgeInsets.symmetric(vertical: 0), // Garder petit
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
),
),
),
Expanded(
child: BlocBuilder<NetworkBloc, NetworkState>(
builder: (context, state) {
if (state is NetworkInitial || state is NetworkLoading) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: CoreShimmer(itemCount: 6),
);
}
if (state is NetworkError) {
return Center(
child: Text(
state.message,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
),
);
}
if (state is NetworkLoaded) {
if (state.items.isEmpty) {
return const Center(
child: Text('Aucun résultat trouvé.', style: AppTypography.subtitleSmall),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items[index];
return CoreCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
MiniAvatar(
imageUrl: item.avatarUrl,
fallbackText: item.name.isNotEmpty ? item.name[0] : 'U',
size: 40,
isOnline: item.isConnected, // Pastille verte simulée
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: AppTypography.actionText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.subtitle != null)
Text(
item.subtitle!,
style: AppTypography.subtitleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// Badge dynamique fonction du type ; tap pour Suivre / Ne plus suivre
if (item.type == 'Organization')
InfoBadge.neutral('Organisation')
else
GestureDetector(
onTap: () {
context.read<NetworkBloc>().add(ToggleFollowRequested(item.id));
},
child: InfoBadge(
text: item.isConnected ? 'Connecté' : 'Suivre',
backgroundColor: item.isConnected ? AppColors.lightSurface : AppColors.primaryGreen,
textColor: item.isConnected ? AppColors.textPrimaryLight : Colors.white,
),
),
],
),
);
},
);
}
return const SizedBox.shrink();
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/shared/mini_header_bar.dart';
import '../../shared/widgets/core_card.dart';
import '../../shared/widgets/mini_avatar.dart';
import '../../shared/widgets/action_row.dart';
import '../../shared/widgets/dynamic_fab.dart';
import '../../shared/widgets/core_shimmer.dart';
import '../../shared/widgets/info_badge.dart';
import '../../shared/design_system/tokens/app_typography.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import '../../core/di/injection.dart';
import '../../features/feed/presentation/bloc/unified_feed_bloc.dart';
import '../../features/feed/presentation/bloc/unified_feed_event.dart';
import '../../features/feed/presentation/bloc/unified_feed_state.dart';
import '../../features/feed/domain/entities/feed_item.dart';
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
/// UnionFlow Mobile - Flux d'Actualité Unifié (Mode DRY)
/// N'affiche que des CoreCard dynamiques en écoutant UnifiedFeedBloc.
class UnifiedFeedPage extends StatelessWidget {
const UnifiedFeedPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<UnifiedFeedBloc>()..add(const LoadFeedRequested()),
child: const _UnifiedFeedView(),
);
}
}
class _UnifiedFeedView extends StatefulWidget {
const _UnifiedFeedView();
@override
State<_UnifiedFeedView> createState() => _UnifiedFeedViewState();
}
class _UnifiedFeedViewState extends State<_UnifiedFeedView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
context.read<UnifiedFeedBloc>().add(FeedLoadMoreRequested());
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return BlocListener<UnifiedFeedBloc, UnifiedFeedState>(
listenWhen: (previous, current) =>
current is UnifiedFeedLoaded && (current as UnifiedFeedLoaded).loadMoreErrorMessage != null,
listener: (context, state) {
final loadedState = state as UnifiedFeedLoaded;
final msg = loadedState.loadMoreErrorMessage;
if (msg != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
);
context.read<UnifiedFeedBloc>().add(ClearLoadMoreError());
}
},
child: Scaffold(
appBar: MiniHeaderBar(
title: 'Accueil',
trailing: Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ctx.read<UnifiedFeedBloc>().add(const LoadFeedRequested(isRefresh: true)),
tooltip: 'Actualiser',
),
),
),
body: RefreshIndicator(
color: AppColors.primaryGreen,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
onRefresh: () async {
context.read<UnifiedFeedBloc>().add(const LoadFeedRequested(isRefresh: true));
},
child: BlocBuilder<UnifiedFeedBloc, UnifiedFeedState>(
builder: (context, state) {
if (state is UnifiedFeedInitial || state is UnifiedFeedLoading) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: CoreShimmer(itemCount: 4),
);
}
if (state is UnifiedFeedError) {
return Center(
child: Text(
state.message,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
),
);
}
if (state is UnifiedFeedLoaded) {
if (state.items.isEmpty) {
return const Center(
child: Text('Aucune activité récente.', style: AppTypography.subtitleSmall),
);
}
return ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax ? state.items.length : state.items.length + 1,
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final item = state.items[index];
return _buildFeedCard(context, item);
},
);
}
return const SizedBox.shrink();
},
),
),
floatingActionButton: DynamicFAB(
icon: Icons.add,
onPressed: () => _showCreateBottomSheet(context),
),
),
);
}
Widget _buildFeedCard(BuildContext context, FeedItem item) {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MiniAvatar(
imageUrl: item.authorAvatarUrl,
fallbackText: item.authorName.isNotEmpty ? item.authorName[0] : 'U',
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.authorName,
style: AppTypography.actionText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Row(
children: [
Text(
_formatDate(item.createdAt),
style: AppTypography.subtitleSmall,
),
if (item.type != FeedItemType.post) ...[
const SizedBox(width: 6),
InfoBadge(
text: item.type.name.toUpperCase(),
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
textColor: AppColors.primaryGreen,
),
]
],
),
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
Icons.more_vert,
size: 16,
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
onPressed: () => _showPostOptionsMenu(context, item),
)
],
),
const SizedBox(height: 8),
Text(
item.content,
style: AppTypography.bodyTextSmall,
),
const SizedBox(height: 8),
ActionRow(
likesCount: item.likesCount,
commentsCount: item.commentsCount,
isLiked: item.isLikedByMe,
onLike: () {
context.read<UnifiedFeedBloc>().add(FeedItemLiked(item.id));
},
onComment: () => _onComment(context, item),
onShare: () => Share.share('${item.content}\n${item.authorName}', subject: item.type.name),
customActionLabel: item.customActionLabel,
customActionIcon: item.customActionLabel != null ? Icons.arrow_forward : null,
onCustomAction: item.customActionLabel != null ? () => _onCustomAction(context, item) : null,
),
],
),
);
}
void _showCreateBottomSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.post_add_outlined),
title: const Text('Nouveau post'),
onTap: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Création de post — à brancher sur l\'API.')),
);
},
),
ListTile(
leading: const Icon(Icons.volunteer_activism_outlined),
title: const Text('Demande d\'aide'),
onTap: () {
Navigator.pop(ctx);
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const DemandesAidePageWrapper()),
);
},
),
],
),
),
);
}
void _onComment(BuildContext context, FeedItem item) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Commentaires pour "${item.content.length > 30 ? "${item.content.substring(0, 30)}..." : item.content}" — à venir.'),
behavior: SnackBarBehavior.floating,
),
);
}
void _onCustomAction(BuildContext context, FeedItem item) {
if (item.actionUrlTarget != null && item.actionUrlTarget!.isNotEmpty) {
final uri = Uri.tryParse(item.actionUrlTarget!);
if (uri != null) {
launchUrl(uri, mode: LaunchMode.externalApplication).catchError((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir le lien.')),
);
});
return;
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Action: ${item.customActionLabel ?? ""}'), behavior: SnackBarBehavior.floating),
);
}
void _showPostOptionsMenu(BuildContext context, FeedItem item) {
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Modifier'),
onTap: () => Navigator.pop(ctx),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Supprimer'),
onTap: () => Navigator.pop(ctx),
),
ListTile(
leading: const Icon(Icons.flag_outlined),
title: const Text('Signaler'),
onTap: () => Navigator.pop(ctx),
),
],
),
),
);
}
String _formatDate(DateTime date) {
// Dans un vrai projet, utiliser intl pour "Il y a 2h"
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/shared/mini_header_bar.dart';
import '../../shared/widgets/core_card.dart';
import '../../shared/widgets/core_shimmer.dart';
import '../../shared/design_system/tokens/app_typography.dart';
import '../../shared/design_system/tokens/app_colors.dart';
import '../../core/di/injection.dart';
import '../../features/notifications/presentation/bloc/notification_bloc.dart';
import '../../features/notifications/presentation/bloc/notification_event.dart';
import '../../features/notifications/presentation/bloc/notification_state.dart';
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
import '../../features/epargne/presentation/pages/epargne_page.dart';
import '../../features/events/presentation/pages/events_page_wrapper.dart';
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
import '../../features/organizations/presentation/pages/organizations_page_wrapper.dart';
import '../../features/members/presentation/pages/members_page_wrapper.dart';
void _navigateForCategory(BuildContext context, String category) {
switch (category.toLowerCase()) {
case 'finance':
case 'cotisation':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ContributionsPageWrapper()),
);
break;
case 'event':
case 'events':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const EventsPageWrapper()),
);
break;
case 'epargne':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const EpargnePage()),
);
break;
case 'adhesion':
case 'adhesions':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AdhesionsPageWrapper()),
);
break;
case 'organisation':
case 'organization':
case 'organisations':
case 'organizations':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const OrganizationsPageWrapper()),
);
break;
case 'member':
case 'membre':
case 'members':
case 'membres':
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const MembersPageWrapper()),
);
break;
default:
break;
}
}
/// UnionFlow Mobile - Onglet Notifications (Mode DRY)
/// Liste de notifications avec coloration subtile pour les non-lues.
class NotificationPage extends StatelessWidget {
const NotificationPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<NotificationBloc>()..add(LoadNotificationsRequested()),
child: const _NotificationView(),
);
}
}
class _NotificationView extends StatelessWidget {
const _NotificationView();
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: const MiniHeaderBar(title: 'Notifications'),
body: BlocBuilder<NotificationBloc, NotificationState>(
builder: (context, state) {
if (state is NotificationInitial || state is NotificationLoading) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: CoreShimmer(itemCount: 8),
);
}
if (state is NotificationError) {
return Center(
child: Text(
state.message,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
),
);
}
if (state is NotificationLoaded) {
if (state.items.isEmpty) {
return const Center(
child: Text('Aucune notification.', style: AppTypography.subtitleSmall),
);
}
return ListView.builder(
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items[index];
final unreadColor = isDark ? const Color(0xFF1B2E26) : const Color(0xFFE8F5E9);
return InkWell(
onTap: () {
if (!item.isRead) {
context.read<NotificationBloc>().add(NotificationMarkedAsRead(item.id));
}
_navigateForCategory(context, item.category);
},
child: Container(
color: item.isRead ? Colors.transparent : unreadColor,
child: CoreCard(
margin: EdgeInsets.zero, // Retire la marge pour coller les items de liste
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
item.category == 'finance' ? Icons.payment : Icons.event,
color: item.isRead
? (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)
: AppColors.primaryGreen,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
item.title,
style: AppTypography.actionText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
_formatDate(item.date),
style: AppTypography.subtitleSmall,
),
],
),
const SizedBox(height: 4),
Text(
item.body,
style: AppTypography.bodyTextSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
},
);
}
return const SizedBox.shrink();
},
),
);
}
String _formatDate(DateTime date) {
// Mock simple (dans un vrai cas, utiliser 'intl' ou 'timeago')
final diff = DateTime.now().difference(date);
if (diff.inHours < 24) return 'il y a ${diff.inHours}h';
return '${date.day}/${date.month}';
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../../../shared/design_system/tokens/app_colors.dart';
import '../../../shared/design_system/tokens/app_typography.dart';
import '../../../shared/widgets/mini_avatar.dart';
/// UnionFlow Mobile - Composant DRY : MiniHeaderBar
/// Remplace l'AppBar massive. Maximum 35-40px de hauteur.
class MiniHeaderBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final String? profileImageUrl;
final Widget? trailing;
const MiniHeaderBar({
Key? key,
required this.title,
this.profileImageUrl,
this.trailing,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 8, // Respecte la Status Bar
bottom: 8,
left: 16,
right: 16,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Bouton Menu/Profil à gauche
GestureDetector(
onTap: () {
Scaffold.of(context).openDrawer();
},
child: MiniAvatar(
imageUrl: profileImageUrl,
fallbackText: 'ME',
size: 28,
),
),
// Titre central (Petit)
Text(
title,
style: AppTypography.headerSmall.copyWith(
color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight,
),
),
if (trailing != null) trailing! else const SizedBox(width: 28),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(40.0);
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../../shared/design_system/tokens/app_colors.dart';
import '../../../shared/design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : MiniMetricWidget
/// Affiche une métrique sous forme "Label (10px) / Valeur (13px)".
/// Utilisé dans le Dashboard financier compressé.
class MiniMetricWidget extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
final CrossAxisAlignment alignment;
const MiniMetricWidget({
Key? key,
required this.label,
required this.value,
this.valueColor,
this.alignment = CrossAxisAlignment.start,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: alignment,
children: [
Text(
label.toUpperCase(),
style: AppTypography.badgeText.copyWith(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.actionText.copyWith(
color: valueColor ?? (Theme.of(context).brightness == Brightness.dark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight),
),
),
],
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../../features/profile/presentation/pages/profile_page_wrapper.dart';
import '../../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
import '../../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
import '../../../features/settings/presentation/pages/system_settings_page.dart';
import '../../../features/help/presentation/pages/help_support_page.dart';
import '../../../shared/design_system/unionflow_design_system.dart';
import '../../../shared/widgets/mini_avatar.dart';
import '../../../shared/widgets/action_row.dart';
import '../../../shared/widgets/info_badge.dart';
/// UnionFlow Mobile - Composant DRY : Menu Profil Latéral
/// Un tiroir (drawer) de style réseau social (Twitter/Facebook) très épuré.
class ProfileDrawer extends StatelessWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Drawer(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocBuilder<AuthBloc, AuthState>(
buildWhen: (prev, curr) => curr is AuthAuthenticated || prev is AuthAuthenticated,
builder: (context, authState) {
final user = authState is AuthAuthenticated ? authState.user : null;
final name = user?.fullName ?? 'Utilisateur';
final email = user?.email ?? '';
final initial = name.isNotEmpty ? name[0].toUpperCase() : 'U';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MiniAvatar(fallbackText: initial, size: 48, isOnline: user != null),
const SizedBox(height: 12),
Text(
name,
style: AppTypography.headerSmall.copyWith(
color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight,
),
),
const SizedBox(height: 4),
Text(
email,
style: AppTypography.subtitleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
Text('', style: AppTypography.actionText),
Text('Cotisations', style: AppTypography.subtitleSmall),
const SizedBox(width: 16),
Text('', style: AppTypography.actionText),
Text('Événements attendus', style: AppTypography.subtitleSmall),
],
),
],
),
);
},
),
Divider(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, height: 1),
// Liens / Actions (factorisés)
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildDrawerItem(context, Icons.person_outline, 'Mon Profil', () { Navigator.pop(context); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const ProfilePageWrapper())); }),
_buildDrawerItem(context, Icons.history, 'Historique', () { Navigator.pop(context); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const ContributionsPageWrapper())); }),
_buildDrawerItem(context, Icons.favorite_border, 'Solidarité', () { Navigator.pop(context); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const DemandesAidePageWrapper())); }),
_buildDrawerItem(context, Icons.settings_outlined, 'Paramètres', () { Navigator.pop(context); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const SystemSettingsPage())); }),
_buildDrawerItem(context, Icons.help_outline, 'Aide & Support', () { Navigator.pop(context); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const HelpSupportPage())); }),
],
),
),
Divider(color: isDark ? AppColors.darkBorder : AppColors.lightBorder, height: 1),
// Bouton Déconnexion
Padding(
padding: const EdgeInsets.all(16.0),
child: InkWell(
onTap: () {
context.read<AuthBloc>().add(AuthLogoutRequested());
Navigator.pop(context); // Fermer le drawer
},
child: Row(
children: [
const Icon(Icons.logout, color: AppColors.error, size: 20),
const SizedBox(width: 16),
Text(
'Se déconnecter',
style: AppTypography.actionText.copyWith(color: AppColors.error),
),
],
),
),
),
],
),
),
);
}
Widget _buildDrawerItem(BuildContext context, IconData icon, String title, VoidCallback onTap) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 14.0),
child: Row(
children: [
Icon(
icon,
size: 22,
color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight,
),
const SizedBox(width: 20),
Text(
title,
style: AppTypography.headerSmall.copyWith(
fontWeight: FontWeight.w500,
color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight,
),
),
],
),
),
);
}
}