2026-01-13 11:36:24 +05:30

887 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:onufitness/constants/api_enum_constant.dart';
import 'package:onufitness/constants/asset_constants.dart';
import 'package:onufitness/constants/color_constant.dart';
import 'package:onufitness/constants/text_constant.dart';
import 'package:onufitness/screens/echoboard/widget/image_zoom.dart';
import 'package:onufitness/screens/echoboard/models/post_model.dart';
import 'package:onufitness/screens/u_vault/widgets/video_card.dart';
import 'package:onufitness/utils/custom_cache_manager.dart';
import 'package:onufitness/utils/custom_sneakbar.dart';
import 'package:onufitness/utils/helper_function.dart';
import 'package:shimmer/shimmer.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import 'dart:developer' as developer;
class PostCard extends StatefulWidget {
final SocialPostItem post;
final VoidCallback onLikePressed;
final VoidCallback onLikeLongPressed;
final VoidCallback onCommentPressed;
final VoidCallback onSharePressed;
final VoidCallback? onMorePressed;
final VoidCallback? onProfilePressed;
final VoidCallback? onPostTap;
final Function(int pollId, int optionId)? onPollOptionSelected;
final GlobalKey? likeButtonKey;
final bool isTribeEchoboard;
final bool isThreeDotButtonEnabled;
const PostCard({
super.key,
required this.post,
required this.onLikePressed,
required this.onCommentPressed,
required this.onSharePressed,
this.onMorePressed,
this.onPollOptionSelected,
required this.onLikeLongPressed,
this.likeButtonKey,
this.onProfilePressed,
this.isTribeEchoboard = false,
this.onPostTap,
this.isThreeDotButtonEnabled = true,
});
@override
PostCardState createState() => PostCardState();
}
class PostCardState extends State<PostCard> with AutomaticKeepAliveClientMixin {
String? _selectedPollOptionId;
//Do not remove _isVoting....
bool _isVoting = false;
bool _isContentExpanded = false;
static const int _contentPreviewLength = 150;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// DON'T initialize video here - only set up poll state
_initializePollState();
}
void _initializePollState() {
// Check if user has already voted on this poll
if (_hasUserVotedOnAnyOption()) {
// Set the selected option ID if user has already voted
_setSelectedOptionFromUserVote();
}
}
@override
void didUpdateWidget(PostCard oldWidget) {
super.didUpdateWidget(oldWidget);
// If the post data changed (especially poll data), we might need to reset UI states
if (oldWidget.post.postId != widget.post.postId ||
oldWidget.post.poll?.pollPostId != widget.post.poll?.pollPostId) {
// Reset voting state
_isVoting = false;
// Update selected option based on API data
if (_hasUserVotedOnAnyOption()) {
_setSelectedOptionFromUserVote();
} else {
_selectedPollOptionId = null;
}
}
// If reaction changed, we should update the UI as well
if (oldWidget.post.postReactionTypeId != widget.post.postReactionTypeId) {
developer.log(
"Reaction type changed: ${oldWidget.post.postReactionTypeId} -> ${widget.post.postReactionTypeId}",
);
}
}
@override
void dispose() {
super.dispose();
}
// Check if the user has voted on any option in this poll
bool _hasUserVotedOnAnyOption() {
if (widget.post.poll?.options == null) return false;
return widget.post.poll!.options!.any(
(option) => option.hasUserVoted == true,
);
}
// Set the selected option ID from the user's vote
void _setSelectedOptionFromUserVote() {
if (widget.post.poll?.options != null) {
for (var option in widget.post.poll!.options!) {
if (option.hasUserVoted == true && option.optionId != null) {
_selectedPollOptionId = option.optionId.toString();
break;
}
}
}
}
bool _hasVideo() {
return widget.post.mediaFiles?.any(
(media) => media.mediaType?.toLowerCase().contains('video') ?? false,
) ??
false;
}
bool _hasImage() {
return widget.post.mediaFiles?.any(
(media) => media.mediaType?.toLowerCase().contains('image') ?? false,
) ??
false;
}
bool _hasPoll() {
return widget.post.poll != null;
}
//...............................................................................
// bool _isPollExpired() {
// if (widget.post.poll?.expiryDate == null) return false;
// return DateTime.now().isAfter(widget.post.poll!.expiryDate!);
// }
//...............................................................................
String? _getFirstImageUrl() {
if (!_hasImage()) return null;
final imageMedia = widget.post.mediaFiles!.firstWhere(
(media) => media.mediaType?.toLowerCase().contains('image') ?? false,
orElse: () => MediaFile(),
);
return imageMedia.mediaFile;
}
String? _getFirstVideoUrl() {
if (!_hasVideo()) return null;
final videoMedia = widget.post.mediaFiles!.firstWhere(
(media) => media.mediaType?.toLowerCase().contains('video') ?? false,
orElse: () => MediaFile(),
);
return videoMedia.mediaFile;
}
String? _getFirstVideoThumbnail() {
if (!_hasVideo()) return null;
final videoMedia = widget.post.mediaFiles!.firstWhere(
(media) => media.mediaType?.toLowerCase().contains('video') ?? false,
orElse: () => MediaFile(),
);
return videoMedia.thumbnailFile;
}
String _formatDateTime(DateTime? dateTime) {
if (dateTime == null) return '';
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 7) {
return DateFormat('MMM d, yyyy').format(dateTime);
} else if (difference.inDays > 0) {
return '${difference.inDays}d ago';
} else if (difference.inHours > 0) {
return '${difference.inHours}h ago';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}min ago';
} else {
return 'Just now';
}
}
void _votePoll(int optionId) async {
developer.log("Vote poll function triggered for option: $optionId");
//...............................................................................
// Check if user has already voted or poll has expired....................................
//...............................................................................
// if (_hasUserVotedOnAnyOption()) {
// developer.log("User already voted on this poll");
// customSnackbar(
// title: "Voted",
// message: "You have already voted on this poll",
// duration: 1,
// color: Colors.red.withValues(alpha: 0.7),
// textColor: Colors.white,
// );
// return;
// }
//...............................................................................
// Checking if Poll is expired or not.............................................
//...............................................................................
// if (_isPollExpired()) {
// customSnackbar(
// title: "Expired",
// message: "Poll expired, tou cannot vote now",
// duration: 1,
// color: Colors.red.withValues(alpha: 0.7),
// textColor: Colors.white,
// );
// return;
// }
//...............................................................................
// Check if already votted that particular POLL Just now
//...............................................................................
// if (_isVoting) {
// developer.log("Already in voting process");
// return;
// }
//...............................................................................
// Just update the selected option immediately - no loading indicator or animation
setState(() {
_selectedPollOptionId = optionId.toString();
_isVoting = true;
});
developer.log("Setting selected option: $_selectedPollOptionId");
try {
// Call the API through the parent callback
if (widget.onPollOptionSelected != null &&
widget.post.poll?.pollPostId != null) {
developer.log(
"Calling onPollOptionSelected callback with pollId: ${widget.post.poll!.pollPostId}, optionId: $optionId",
);
// Call the API - the parent will le updating the post data
widget.onPollOptionSelected!(widget.post.poll!.pollPostId!, optionId);
}
} catch (e) {
// Reset if there's an error
developer.log("Error during voting: $e");
setState(() {
_selectedPollOptionId = null;
_isVoting = false;
});
customSnackbar(
title: "Error",
message: 'Failed to submit your vote. Please try after some time.',
duration: 1,
color: Colors.red.withValues(alpha: 0.7),
textColor: Colors.white,
);
}
}
//...............................................................................
// Check if user has already voted or poll has expired....................................
//...............................................................................
// bool _hasUserVoted() {
// return _hasUserVotedOnAnyOption() || _isPollExpired();
// }
//...............................................................................
//...............................................................................
// Check if content needs expansion
bool _shouldShowSeeMore() {
final content = widget.post.content;
return content != null && content.length > _contentPreviewLength;
}
// Get content to display based on expansion state
String _getDisplayContent() {
final content = widget.post.content ?? '';
if (!_shouldShowSeeMore() || _isContentExpanded) {
return content;
}
return content.substring(0, _contentPreviewLength);
}
Widget _buildProfileAvatar() {
return InkWell(
onTap: widget.onProfilePressed,
child: ClipRRect(
borderRadius: BorderRadius.circular(30.r),
child:
widget.post.profilePicture != null &&
widget.post.profilePicture!.trim().isNotEmpty
? CachedNetworkImage(
imageUrl: widget.post.profilePicture!,
width: 50.r,
height: 50.r,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
placeholder: (context, url) => buildAvatarPlaceholder(),
errorWidget:
(context, url, error) => buildAvatarPlaceholder(),
)
: buildAvatarPlaceholder(),
),
);
}
Widget buildAvatarPlaceholder() {
return CircleAvatar(
backgroundColor: lightGreyColor,
radius: isTablet ? 25.sp : 20.sp,
child: Icon(Icons.person, size: 30, color: Color(darkGreyColor)),
);
}
Widget _getReactionIcon() {
if (widget.post.postReactionTypeId == null) {
return Image.asset(
AssetConstants.likeIcon,
height: isTablet ? 25.h : 20.h,
width: isTablet ? 25.w : 20.w,
);
}
switch (widget.post.postReactionTypeId) {
case ApiEnum.highFive:
return Image.asset(
AssetConstants.highFiveIcon,
height: isTablet ? 25.h : 20.h,
width: isTablet ? 25.w : 20.w,
);
case ApiEnum.heart:
return Image.asset(
AssetConstants.heartReactionicon,
height: isTablet ? 25.h : 20.h,
width: isTablet ? 25.w : 20.w,
);
case ApiEnum.beastMode:
return Image.asset(
AssetConstants.beastModeIcon,
height: isTablet ? 25.h : 20.h,
width: isTablet ? 25.w : 20.w,
);
default:
return Image.asset(
AssetConstants.likeIcon,
height: isTablet ? 25.h : 20.h,
width: isTablet ? 25.w : 20.w,
);
}
}
List<TextSpan> parseTextWithBoldFormatting(String text) {
List<TextSpan> spans = [];
RegExp boldRegex = RegExp(r'\*\*(.*?)\*\*');
int lastMatchEnd = 0;
Iterable<RegExpMatch> matches = boldRegex.allMatches(text);
for (RegExpMatch match in matches) {
if (match.start > lastMatchEnd) {
spans.add(
TextSpan(
text: text.substring(lastMatchEnd, match.start),
style: TextStyle(fontSize: smallSizeText, color: Colors.black),
),
);
}
spans.add(
TextSpan(
text: match.group(1),
style: TextStyle(
fontSize: smallSizeText,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
);
lastMatchEnd = match.end;
}
if (lastMatchEnd < text.length) {
spans.add(
TextSpan(
text: text.substring(lastMatchEnd),
style: TextStyle(fontSize: smallSizeText, color: Colors.black),
),
);
}
return spans;
}
String getPostVisibilityText() {
// Adjust these IDs based on your ApiEnum constants
switch (widget.post.postVisibilityId) {
case 1: // Public
return '🌐 Public';
case 2: // Private
return '🔒 Private';
case 3: // Custom
return '⚙️ Exclusive';
default:
return '🌐 Public'; // Default fallback
}
}
// Build content with see more/see less functionality
Widget _buildContentSection() {
if (widget.post.content == null || widget.post.content!.isEmpty) {
return SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.all(12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
children: parseTextWithBoldFormatting(_getDisplayContent()),
),
),
if (_shouldShowSeeMore()) ...[
if (!_isContentExpanded)
Text(
'...',
style: TextStyle(fontSize: smallSizeText, color: Colors.black),
),
SizedBox(height: 4.h),
GestureDetector(
onTap: () {
setState(() {
_isContentExpanded = !_isContentExpanded;
});
},
child: Text(
_isContentExpanded ? 'See less' : 'See more',
style: TextStyle(
fontSize: smallSizeText,
color: Color(primaryColor),
fontWeight: FontWeight.w600,
),
),
),
],
],
),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
return InkWell(
onTap: widget.onPostTap,
child: Container(
color: Colors.white,
margin: EdgeInsets.only(bottom: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.only(left: 10.w, right: 0),
leading: _buildProfileAvatar(),
title: InkWell(
onTap: widget.onProfilePressed,
child: Text(
widget.post.fullName ?? 'Unknown User',
style: TextStyle(
fontSize: mediumSizeText,
fontWeight: FontWeight.w600,
),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.post.userTypeName != null
? widget.post.userTypeName == ApiEnum.coachUserRole &&
widget.post.coachTypeName != null
? Text(
widget.post.coachTypeName!,
style: TextStyle(
fontSize: smallSizeText,
fontWeight: FontWeight.w600,
color: greyTextColor1,
),
)
: Text(
widget.post.userTypeName!,
style: TextStyle(
fontSize: smallSizeText,
fontWeight: FontWeight.w600,
color: greyTextColor1,
),
)
: Container(),
Row(
children: [
Text(
_formatDateTime(widget.post.createdAt),
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w600,
color: greyTextColor1,
),
),
if (widget.post.postVisibilityId != null) ...[
if (widget.isTribeEchoboard == false)
Text(
'',
style: TextStyle(
fontSize: verySmallSizeText,
color: greyTextColor1,
),
),
if (widget.isTribeEchoboard == false)
Text(
getPostVisibilityText(),
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w500,
color: greyTextColor1,
),
),
],
],
),
],
),
trailing:
widget.isThreeDotButtonEnabled
? IconButton(
icon: Icon(Icons.more_vert),
onPressed: widget.onMorePressed,
)
: SizedBox.shrink(),
),
// Content section with see more/see less
_buildContentSection(),
if (_hasImage())
GestureDetector(
onTap: () {
// Navigate to zoom view
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => ImageZoomWidget(
imageUrl: _getFirstImageUrl()!,
heroTag:
'zoom_image_${widget.post.postId}${DateTime.now().millisecondsSinceEpoch}',
),
),
);
},
child: Hero(
tag:
'post_image_${widget.post.postId}${DateTime.now().millisecondsSinceEpoch}',
child: CachedNetworkImage(
imageUrl: _getFirstImageUrl()!,
width: double.infinity,
height: isTablet ? 300.h : 250.h,
fit: BoxFit.cover,
cacheManager: CustomCacheManager.instance,
filterQuality: FilterQuality.low,
placeholder:
(context, url) => Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
width: double.infinity,
height: isTablet ? 300.h : 250.h,
color: Colors.white,
),
),
errorWidget:
(context, url, error) => Container(
height: isTablet ? 300.h : 250.h,
color: Colors.grey[200],
child: Center(child: Icon(Icons.error)),
),
),
),
),
// Optimized video section - lazy loading with thumbnail
if (_hasVideo())
VideoCard(
category: "",
videoUrl: _getFirstVideoUrl()!,
isMyVideoScreen: false,
title: null,
thumbnailUrl: _getFirstVideoThumbnail(),
),
if (_hasPoll())
Stack(
children: [
Padding(
padding: EdgeInsets.only(
left: 12.w,
right: 18.w,
top: 10.w,
bottom: 10.w,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.post.poll!.question ?? 'Poll Question',
style: TextStyle(
fontSize: mediumSizeText,
fontWeight: FontWeight.w600,
),
),
),
if (widget.post.poll?.totalPollVotes != null)
Text(
'${widget.post.poll!.totalPollVotes} votes',
style: TextStyle(
fontSize: smallSizeText,
color: Colors.grey[600],
),
),
],
),
SizedBox(height: 12.h),
if (widget.post.poll!.options != null)
...widget.post.poll!.options!.map(
(option) => _buildPollOption(option),
),
// Expired and Expiring date is showing.............................................................................
// if (widget.post.poll!.expiryDate != null)
// Padding(
// padding: EdgeInsets.only(top: 12.h),
// child: Row(
// children: [
// Icon(
// _isPollExpired()
// ? Icons.lock_clock
// : Icons.access_time,
// size: 16.sp,
// color:
// _isPollExpired()
// ? Colors.red
// : Colors.grey[600],
// ),
// SizedBox(width: 4.w),
// Text(
// _isPollExpired()
// ? 'Poll ended: ${_formatDateTime(widget.post.poll!.expiryDate)}'
// : 'Poll ends: ${_formatDateTime(widget.post.poll!.expiryDate)}',
// style: TextStyle(
// fontSize: verySmallSizeText,
// color:
// _isPollExpired()
// ? Colors.red
// : Colors.grey[600],
// ),
// ),
// ],
// ),
// ),
//..............................................................................................
],
),
),
],
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 8.h),
child: Row(
children: [
Row(
children: [
IconButton(
key: widget.likeButtonKey,
icon: _getReactionIcon(),
onPressed: () {
widget.onLikePressed();
},
onLongPress: () {
widget.onLikeLongPressed();
},
),
Text(
'${widget.post.totalPostReactions ?? 0}',
style: TextStyle(
fontSize: smallSizeText,
color:
widget.post.postReactionTypeId != null
? Colors.black
: null,
),
),
],
),
SizedBox(width: 10.w),
Row(
children: [
IconButton(
icon: Image.asset(
AssetConstants.commentsLogo,
height: 20.h,
width: 20.w,
),
onPressed: widget.onCommentPressed,
),
Text(
'${widget.post.totalPostComments ?? 0}',
style: TextStyle(fontSize: smallSizeText),
),
],
),
SizedBox(width: 10.w),
Row(
children: [
IconButton(
icon: Image.asset(
AssetConstants.shareLogo,
height: 20.h,
width: 20.w,
),
onPressed: widget.onSharePressed,
),
// Text('0', style: TextStyle(fontSize: smallSizeText)),
],
),
SizedBox(width: 20.w),
],
),
),
Divider(height: 1.h, thickness: 1.h, color: Colors.grey[200]),
],
),
),
);
}
Widget _buildPollOption(Option option) {
final totalVotes = widget.post.poll?.totalPollVotes ?? 0;
final optionVotes = option.totalOptionPollVotes ?? 0;
double percentage = 0.0;
if (totalVotes > 0 && optionVotes >= 0) {
percentage = optionVotes / totalVotes;
if (percentage.isNaN || percentage.isInfinite) {
percentage = 0.0;
}
percentage = percentage.clamp(0.0, 1.0);
}
final bool isSelected =
(option.optionId != null &&
_selectedPollOptionId == option.optionId.toString()) ||
(option.hasUserVoted == true && _selectedPollOptionId == null);
//................................................................
// final isDisabled = _isPollExpired() || _hasUserVoted();
//................................................................
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: InkWell(
//........ Checking Expired or not................................
// onTap:
// isDisabled
// ? null
// : () {
// developer.log("Poll option tapped: ${option.optionId}");
// if (option.optionId != null) {
// _votePoll(option.optionId!);
// }
// },
//................................................................
onTap: () {
developer.log("Poll option tapped: ${option.optionId}");
if (option.optionId != null) {
_votePoll(option.optionId!);
}
},
borderRadius: BorderRadius.circular(8.r),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 2.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: isTablet ? 45.h : 40.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: containerBorderColor, width: 2),
),
child: Stack(
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(6.r),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.white,
valueColor: AlwaysStoppedAnimation<Color>(
isSelected
? Color(primaryColor)
: Color(primaryColor).withValues(alpha: 0.3),
),
minHeight: isTablet ? 45.h : 40.h,
),
),
),
// Content overlay
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Center(
child: Row(
children: [
if (isSelected) ...[
Icon(
Icons.check_circle,
color: Colors.white,
size: regularSizeText,
),
SizedBox(width: 8.w),
],
Expanded(
child: Text(
option.optionLabel ?? '',
style: TextStyle(
fontSize: smallSizeText,
color:
isSelected
? Colors.black
: Colors.black87,
fontWeight:
isSelected
? FontWeight.w500
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'${(percentage * 100).toInt()}% ($optionVotes)',
style: TextStyle(
fontSize: smallSizeText,
color:
isSelected ? Colors.black : Colors.black87,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
],
),
),
),
);
}
}