Files
unionflow-mobile-apps/lib/presentation/feed/unified_feed_page.dart
dahoud 7cd7c6fc9e feat(shared): legacy presentation/ + shared design system + widgets
- lib/presentation : pages legacy (explore/network, notifications) avec BLoC
- lib/shared/design_system : UnionFlow Design System v2 (tokens, components)
  + MD3 tokens + module_colors par feature
- lib/shared/widgets : widgets transversaux (core_card, core_shimmer,
  error_widget, loading_widget, powered_by_lions_dev, etc.)
- lib/shared/constants + utils
2026-04-15 20:27:23 +00:00

332 lines
11 KiB
Dart

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.primary,
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.primary.withOpacity(0.1),
textColor: AppColors.primary,
),
]
],
),
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
Icons.more_vert,
size: 16,
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondary,
),
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 false;
});
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')}';
}
}