332 lines
11 KiB
Dart
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.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 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')}';
|
|
}
|
|
}
|