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:
Patrick Britton 2026-02-01 13:58:01 -06:00
parent 2d5323a310
commit 6741c193e1
2 changed files with 171 additions and 76 deletions

View file

@ -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,
),
], ],
); );
} }

View file

@ -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,
),
),
],
),
);
}
} }