Copy threaded conversation card design to regular post cards
- Update PostActions to include reactions with full state management - Add reaction toggle functionality with optimistic updates - Redesign PostActions layout to match threaded conversation: - Reactions section at top (full width) - Left-aligned action buttons (Reply, Like, Save, Share) - Reply button as expanded ElevatedButton - Icon buttons with background styling - Update sojorn_post_card design: - Add card container with border and shadow - Remove chain context display - Remove reply boxes - Match threaded conversation visual style - Add proper reaction state seeding and sync - Import Google Fonts and ReactionStrip
This commit is contained in:
parent
2d5323a310
commit
6741c193e1
|
|
@ -1,11 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../sojorn_snackbar.dart';
|
import '../sojorn_snackbar.dart';
|
||||||
|
import '../reactions/reaction_strip.dart';
|
||||||
|
|
||||||
/// Post actions with a vibrant, clear, and energetic design.
|
/// Post actions with a vibrant, clear, and energetic design.
|
||||||
///
|
///
|
||||||
|
|
@ -33,12 +35,26 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
late bool _isSaved;
|
late bool _isSaved;
|
||||||
bool _isLiking = false;
|
bool _isLiking = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
// Reaction state
|
||||||
|
final Map<String, int> _reactionCounts = {};
|
||||||
|
final Set<String> _myReactions = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isLiked = widget.post.isLiked ?? false;
|
_isLiked = widget.post.isLiked ?? false;
|
||||||
_isSaved = widget.post.isSaved ?? false;
|
_isSaved = widget.post.isSaved ?? false;
|
||||||
|
_seedReactionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seedReactionState() {
|
||||||
|
if (widget.post.reactions != null) {
|
||||||
|
_reactionCounts.addAll(widget.post.reactions!);
|
||||||
|
}
|
||||||
|
if (widget.post.myReactions != null) {
|
||||||
|
_myReactions.addAll(widget.post.myReactions!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
|
|
@ -121,42 +137,147 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleReaction(String emoji) async {
|
||||||
|
final previousCounts = Map<String, int>.from(_reactionCounts);
|
||||||
|
final previousMine = Set<String>.from(_myReactions);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (_myReactions.contains(emoji)) {
|
||||||
|
_myReactions.remove(emoji);
|
||||||
|
final next = (_reactionCounts[emoji] ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
_reactionCounts.remove(emoji);
|
||||||
|
} else {
|
||||||
|
_reactionCounts[emoji] = next;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_myReactions.isNotEmpty) {
|
||||||
|
final previousEmoji = _myReactions.first;
|
||||||
|
_myReactions.clear();
|
||||||
|
final prevCount = (_reactionCounts[previousEmoji] ?? 1) - 1;
|
||||||
|
if (prevCount <= 0) {
|
||||||
|
_reactionCounts.remove(previousEmoji);
|
||||||
|
} else {
|
||||||
|
_reactionCounts[previousEmoji] = prevCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_myReactions.add(emoji);
|
||||||
|
_reactionCounts[emoji] = (_reactionCounts[emoji] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final response = await api.toggleReaction(widget.post.id, emoji);
|
||||||
|
if (!mounted) return;
|
||||||
|
final updatedCounts = response['reactions'] as Map<String, dynamic>?;
|
||||||
|
final updatedMine = response['my_reactions'] as List<dynamic>?;
|
||||||
|
|
||||||
|
if (updatedCounts != null) {
|
||||||
|
setState(() {
|
||||||
|
_reactionCounts.clear();
|
||||||
|
_reactionCounts.addAll(
|
||||||
|
updatedCounts.map((key, value) => MapEntry(key, value as int))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (updatedMine != null) {
|
||||||
|
setState(() {
|
||||||
|
_myReactions.clear();
|
||||||
|
_myReactions.addAll(updatedMine.map((item) => item.toString()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_reactionCounts.clear();
|
||||||
|
_reactionCounts.addAll(previousCounts);
|
||||||
|
_myReactions.clear();
|
||||||
|
_myReactions.addAll(previousMine);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final allowChain = widget.post.allowChain && widget.post.visibility != 'private' && widget.onChain != null;
|
final allowChain = widget.post.allowChain && widget.post.visibility != 'private' && widget.onChain != null;
|
||||||
|
|
||||||
return Row(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Icon-only action buttons with Row layout
|
// Reactions section - full width
|
||||||
_IconActionButton(
|
ReactionStrip(
|
||||||
icon: Icons.favorite_border,
|
reactions: _reactionCounts,
|
||||||
activeIcon: Icons.favorite,
|
myReactions: _myReactions,
|
||||||
isActive: _isLiked,
|
reactionUsers: {},
|
||||||
isLoading: _isLiking,
|
onToggle: (emoji) => _toggleReaction(emoji),
|
||||||
onPressed: _isLiking ? null : _toggleLike,
|
onAdd: () => _toggleReaction('❤️'), // Default to heart for now
|
||||||
activeColor: AppTheme.brightNavy,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(height: 16),
|
||||||
_IconActionButton(
|
// Actions row - left aligned
|
||||||
icon: Icons.bookmark_border,
|
Row(
|
||||||
activeIcon: Icons.bookmark,
|
children: [
|
||||||
isActive: _isSaved,
|
if (allowChain)
|
||||||
isLoading: _isSaving,
|
Expanded(
|
||||||
onPressed: _isSaving ? null : _toggleSave,
|
child: ElevatedButton.icon(
|
||||||
activeColor: AppTheme.brightNavy,
|
onPressed: widget.onChain,
|
||||||
|
icon: const Icon(Icons.reply, size: 18),
|
||||||
|
label: const Text('Reply'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.brightNavy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (allowChain) const SizedBox(width: 12),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLiking ? null : _toggleLike,
|
||||||
|
icon: Icon(
|
||||||
|
_isLiked ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _isLiked ? Colors.red : AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isSaving ? null : _toggleSave,
|
||||||
|
icon: Icon(
|
||||||
|
_isSaved ? Icons.bookmark : Icons.bookmark_border,
|
||||||
|
color: _isSaved ? AppTheme.brightNavy : AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _sharePost,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.share_outlined,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 24),
|
|
||||||
_IconActionButton(
|
|
||||||
icon: Icons.share_outlined,
|
|
||||||
onPressed: _sharePost,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (allowChain)
|
|
||||||
_IconActionButton(
|
|
||||||
icon: Icons.reply,
|
|
||||||
onPressed: widget.onChain,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,25 @@ class sojornPostCard extends StatelessWidget {
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: _padding,
|
padding: _padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.brightNavy,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppTheme.brightNavy.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
// Header row with menu
|
// Header row with menu
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -101,12 +117,7 @@ class sojornPostCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
// Chain context (if parent exists and not in detail view)
|
|
||||||
if (post.chainParent != null && mode != PostViewMode.detail)
|
|
||||||
_buildChainContext(context),
|
|
||||||
|
|
||||||
const SizedBox(height: AppTheme.spacingMd),
|
|
||||||
|
|
||||||
// Body text
|
// Body text
|
||||||
PostBody(
|
PostBody(
|
||||||
|
|
@ -117,13 +128,15 @@ class sojornPostCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Media (if available)
|
// Media (if available)
|
||||||
if (post.imageUrl != null && post.imageUrl!.isNotEmpty)
|
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
PostMedia(
|
PostMedia(
|
||||||
post: post,
|
post: post,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
PostActions(
|
PostActions(
|
||||||
|
|
@ -137,43 +150,4 @@ class sojornPostCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChainContext(BuildContext context) {
|
|
||||||
final parent = post.chainParent;
|
|
||||||
if (parent == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: AppTheme.spacingSm),
|
|
||||||
padding: const EdgeInsets.all(AppTheme.spacingSm),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.queenPink.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
|
||||||
border: Border(
|
|
||||||
left: BorderSide(
|
|
||||||
color: AppTheme.egyptianBlue.withOpacity(0.5),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.subdirectory_arrow_right,
|
|
||||||
color: AppTheme.royalPurple.withOpacity(0.7),
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
"Replying to @${parent.author?.handle ?? 'unknown'}",
|
|
||||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: AppTheme.royalPurple,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue