Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
176
lib/presentation/dashboard/finance_page.dart
Normal file
176
lib/presentation/dashboard/finance_page.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/presentation/explore/network_page.dart
Normal file
182
lib/presentation/explore/network_page.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
330
lib/presentation/feed/unified_feed_page.dart
Normal file
330
lib/presentation/feed/unified_feed_page.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
196
lib/presentation/notifications/notification_page.dart
Normal file
196
lib/presentation/notifications/notification_page.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
63
lib/presentation/widgets/shared/mini_header_bar.dart
Normal file
63
lib/presentation/widgets/shared/mini_header_bar.dart
Normal 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);
|
||||
}
|
||||
47
lib/presentation/widgets/shared/mini_metric_widget.dart
Normal file
47
lib/presentation/widgets/shared/mini_metric_widget.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
144
lib/presentation/widgets/shared/profile_drawer.dart
Normal file
144
lib/presentation/widgets/shared/profile_drawer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user