feat: add Bluesky-inspired icons with animated heart

Add custom icon widgets recreating Bluesky's social interaction icons:
- Animated heart icon with dramatic 5-phase animation sequence
- Reply/comment icon (speech bubble design)
- Share icon (arrow out of box design)

Heart Animation Features:
- Phase 1: Heart shrinks to nothing
- Phase 2: Red hollow circle expands outward
- Phase 3: Small heart grows from center
- Phase 4: Heart pops to 1.3x with 7-particle burst
- Phase 5: Elastic settle back to 1x

Technical Details:
- Pure Flutter CustomPainter (no external dependencies)
- Uses OverflowBox to prevent layout shift during animation
- SVG path data extracted from Bluesky's Heart2, Reply, and ArrowOutOfBox icons
- All icons use fill style (paths are pre-stroked)
- Bright red color (#FF0033) for liked state
- 800ms animation duration with overlapping phases

Updates PostCard to use new icons and adds temporary like state for testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+325
lib/widgets/icons/animated_heart_icon.dart
···
···
+
import 'dart:math' as math;
+
+
import 'package:flutter/material.dart';
+
+
/// Animated heart icon with outline and filled states
+
///
+
/// Features a dramatic animation sequence:
+
/// 1. Heart shrinks to nothing
+
/// 2. Red hollow circle expands outwards
+
/// 3. Small heart grows from center
+
/// 4. Heart pops to 1.3x with 7 particle dots
+
/// 5. Heart settles back to 1x filled
+
class AnimatedHeartIcon extends StatefulWidget {
+
const AnimatedHeartIcon({
+
required this.isLiked,
+
this.size = 18,
+
this.color,
+
this.likedColor,
+
super.key,
+
});
+
+
final bool isLiked;
+
final double size;
+
final Color? color;
+
final Color? likedColor;
+
+
@override
+
State<AnimatedHeartIcon> createState() => _AnimatedHeartIconState();
+
}
+
+
class _AnimatedHeartIconState extends State<AnimatedHeartIcon>
+
with SingleTickerProviderStateMixin {
+
late AnimationController _controller;
+
+
// Heart scale animations
+
late Animation<double> _heartShrinkAnimation;
+
late Animation<double> _heartGrowAnimation;
+
late Animation<double> _heartPopAnimation;
+
+
// Hollow circle animation
+
late Animation<double> _circleScaleAnimation;
+
late Animation<double> _circleOpacityAnimation;
+
+
// Particle burst animations
+
late Animation<double> _particleScaleAnimation;
+
late Animation<double> _particleOpacityAnimation;
+
+
bool _hasBeenToggled = false;
+
bool _previousIsLiked = false;
+
+
@override
+
void initState() {
+
super.initState();
+
_previousIsLiked = widget.isLiked;
+
+
_controller = AnimationController(
+
duration: const Duration(milliseconds: 800),
+
vsync: this,
+
);
+
+
// Phase 1 (0-15%): Heart shrinks to nothing
+
_heartShrinkAnimation = Tween<double>(begin: 1, end: 0).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0, 0.15, curve: Curves.easeIn),
+
),
+
);
+
+
// Phase 2 (15-40%): Hollow circle expands
+
_circleScaleAnimation = Tween<double>(begin: 0, end: 2).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.15, 0.4, curve: Curves.easeOut),
+
),
+
);
+
+
_circleOpacityAnimation = TweenSequence<double>([
+
TweenSequenceItem(tween: Tween(begin: 0, end: 0.8), weight: 50),
+
TweenSequenceItem(tween: Tween(begin: 0.8, end: 0), weight: 50),
+
]).animate(
+
CurvedAnimation(parent: _controller, curve: const Interval(0.15, 0.4)),
+
);
+
+
// Phase 3 (25-55%): Heart grows from small in center
+
_heartGrowAnimation = Tween<double>(begin: 0.2, end: 1.3).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.25, 0.55, curve: Curves.easeOut),
+
),
+
);
+
+
// Phase 4 (55-65%): Particle burst at peak
+
_particleScaleAnimation = Tween<double>(begin: 0, end: 1).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.55, 0.65, curve: Curves.easeOut),
+
),
+
);
+
+
_particleOpacityAnimation = TweenSequence<double>([
+
TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 30),
+
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 70),
+
]).animate(
+
CurvedAnimation(parent: _controller, curve: const Interval(0.55, 0.75)),
+
);
+
+
// Phase 5 (65-100%): Heart settles to 1x
+
_heartPopAnimation = Tween<double>(begin: 1.3, end: 1).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.65, 1, curve: Curves.elasticOut),
+
),
+
);
+
}
+
+
@override
+
void didUpdateWidget(AnimatedHeartIcon oldWidget) {
+
super.didUpdateWidget(oldWidget);
+
+
if (widget.isLiked != _previousIsLiked) {
+
_hasBeenToggled = true;
+
_previousIsLiked = widget.isLiked;
+
+
if (widget.isLiked) {
+
_controller.forward(from: 0);
+
}
+
}
+
}
+
+
@override
+
void dispose() {
+
_controller.dispose();
+
super.dispose();
+
}
+
+
double _getHeartScale() {
+
if (!widget.isLiked || !_hasBeenToggled) return 1;
+
+
final progress = _controller.value;
+
if (progress < 0.15) {
+
// Phase 1: Shrinking
+
return _heartShrinkAnimation.value;
+
} else if (progress < 0.55) {
+
// Phase 3: Growing from center
+
return _heartGrowAnimation.value;
+
} else {
+
// Phase 5: Settling back
+
return _heartPopAnimation.value;
+
}
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
widget.color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
final effectiveLikedColor = widget.likedColor ?? Colors.red;
+
+
// Use 2.5x size for animation overflow space (for 1.3x scale + particles)
+
final containerSize = widget.size * 2.5;
+
+
return SizedBox(
+
width: widget.size,
+
height: widget.size,
+
child: OverflowBox(
+
maxWidth: containerSize,
+
maxHeight: containerSize,
+
child: SizedBox(
+
width: containerSize,
+
height: containerSize,
+
child: AnimatedBuilder(
+
animation: _controller,
+
builder: (context, child) {
+
return Stack(
+
clipBehavior: Clip.none,
+
alignment: Alignment.center,
+
children: [
+
// Phase 2: Expanding hollow circle
+
if (widget.isLiked &&
+
_hasBeenToggled &&
+
_controller.value >= 0.15 &&
+
_controller.value <= 0.4) ...[
+
Opacity(
+
opacity: _circleOpacityAnimation.value,
+
child: Transform.scale(
+
scale: _circleScaleAnimation.value,
+
child: Container(
+
width: widget.size,
+
height: widget.size,
+
decoration: BoxDecoration(
+
shape: BoxShape.circle,
+
border: Border.all(
+
color: effectiveLikedColor,
+
width: 2,
+
),
+
),
+
),
+
),
+
),
+
],
+
+
// Phase 4: Particle burst (7 dots)
+
if (widget.isLiked &&
+
_hasBeenToggled &&
+
_controller.value >= 0.55 &&
+
_controller.value <= 0.75)
+
..._buildParticleBurst(effectiveLikedColor),
+
+
// Heart icon (all phases)
+
Transform.scale(
+
scale: _getHeartScale(),
+
child: CustomPaint(
+
size: Size(widget.size, widget.size),
+
painter: _HeartIconPainter(
+
color:
+
widget.isLiked
+
? effectiveLikedColor
+
: effectiveColor,
+
filled: widget.isLiked,
+
),
+
),
+
),
+
],
+
);
+
},
+
),
+
),
+
),
+
);
+
}
+
+
List<Widget> _buildParticleBurst(Color color) {
+
const particleCount = 7;
+
final particles = <Widget>[];
+
final containerSize = widget.size * 2.5;
+
+
for (int i = 0; i < particleCount; i++) {
+
final angle = (2 * math.pi * i) / particleCount;
+
final distance = widget.size * 1 * _particleScaleAnimation.value;
+
final dx = math.cos(angle) * distance;
+
final dy = math.sin(angle) * distance;
+
+
particles.add(
+
Positioned(
+
left: containerSize / 2 + dx - 2,
+
top: containerSize / 2 + dy - 2,
+
child: Opacity(
+
opacity: _particleOpacityAnimation.value,
+
child: Container(
+
width: 2,
+
height: 2,
+
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
+
),
+
),
+
),
+
);
+
}
+
+
return particles;
+
}
+
}
+
+
/// Custom painter for heart icon
+
///
+
/// SVG path data from Bluesky's Heart2 icon component
+
class _HeartIconPainter extends CustomPainter {
+
_HeartIconPainter({required this.color, required this.filled});
+
+
final Color color;
+
final bool filled;
+
+
@override
+
void paint(Canvas canvas, Size size) {
+
final paint =
+
Paint()
+
..color = color
+
..style = PaintingStyle.fill;
+
+
// Scale factor to fit 24x24 viewBox into widget size
+
final scale = size.width / 24;
+
canvas.scale(scale);
+
+
final path = Path();
+
+
if (filled) {
+
// Filled heart path from Bluesky
+
path.moveTo(12.489, 21.372);
+
path.cubicTo(21.017, 16.592, 23.115, 10.902, 21.511, 6.902);
+
path.cubicTo(20.732, 4.961, 19.097, 3.569, 17.169, 3.139);
+
path.cubicTo(15.472, 2.761, 13.617, 3.142, 12, 4.426);
+
path.cubicTo(10.383, 3.142, 8.528, 2.761, 6.83, 3.139);
+
path.cubicTo(4.903, 3.569, 3.268, 4.961, 2.49, 6.903);
+
path.cubicTo(0.885, 10.903, 2.983, 16.593, 11.511, 21.373);
+
path.cubicTo(11.826, 21.558, 12.174, 21.558, 12.489, 21.372);
+
path.close();
+
} else {
+
// Outline heart path from Bluesky
+
path.moveTo(16.734, 5.091);
+
path.cubicTo(15.496, 4.815, 14.026, 5.138, 12.712, 6.471);
+
path.cubicTo(12.318, 6.865, 11.682, 6.865, 11.288, 6.471);
+
path.cubicTo(9.974, 5.137, 8.504, 4.814, 7.266, 5.09);
+
path.cubicTo(6.003, 5.372, 4.887, 6.296, 4.346, 7.646);
+
path.cubicTo(3.33, 10.18, 4.252, 14.84, 12, 19.348);
+
path.cubicTo(19.747, 14.84, 20.67, 10.18, 19.654, 7.648);
+
path.cubicTo(19.113, 6.297, 17.997, 5.373, 16.734, 5.091);
+
path.close();
+
+
path.moveTo(21.511, 6.903);
+
path.cubicTo(23.115, 10.903, 21.017, 16.593, 12.489, 21.373);
+
path.cubicTo(12.174, 21.558, 11.826, 21.558, 11.511, 21.373);
+
path.cubicTo(2.983, 16.592, 0.885, 10.902, 2.49, 6.902);
+
path.cubicTo(3.269, 4.96, 4.904, 3.568, 6.832, 3.138);
+
path.cubicTo(8.529, 2.76, 10.384, 3.141, 12.001, 4.424);
+
path.cubicTo(13.618, 3.141, 15.473, 2.76, 17.171, 3.138);
+
path.cubicTo(19.098, 3.568, 20.733, 4.96, 21.511, 6.903);
+
path.close();
+
}
+
+
canvas.drawPath(path, paint);
+
}
+
+
@override
+
bool shouldRepaint(_HeartIconPainter oldDelegate) {
+
return oldDelegate.color != color || oldDelegate.filled != filled;
+
}
+
}
+115
lib/widgets/icons/reply_icon.dart
···
···
+
import 'package:flutter/material.dart';
+
+
/// Reply/comment icon widget
+
///
+
/// Speech bubble icon from Bluesky's design system.
+
/// Supports both outline and filled states.
+
class ReplyIcon extends StatelessWidget {
+
const ReplyIcon({this.size = 18, this.color, this.filled = false, super.key});
+
+
final double size;
+
final Color? color;
+
final bool filled;
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
+
return CustomPaint(
+
size: Size(size, size),
+
painter: _ReplyIconPainter(color: effectiveColor, filled: filled),
+
);
+
}
+
}
+
+
/// Custom painter for reply/comment icon
+
///
+
/// SVG path data from Bluesky's Reply icon component
+
class _ReplyIconPainter extends CustomPainter {
+
_ReplyIconPainter({required this.color, required this.filled});
+
+
final Color color;
+
final bool filled;
+
+
@override
+
void paint(Canvas canvas, Size size) {
+
final paint = Paint()
+
..color = color
+
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
+
+
// Scale factor to fit 24x24 viewBox into widget size
+
final scale = size.width / 24.0;
+
canvas.scale(scale);
+
+
final path = Path();
+
+
if (filled) {
+
// Filled reply icon path from Bluesky
+
// M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22
+
// v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z
+
path.moveTo(22.002, 15);
+
path.cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19);
+
path.lineTo(13.354, 19);
+
path.lineTo(8.627, 22.781);
+
path.cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318);
+
path.lineTo(7.002, 22);
+
path.lineTo(7.002, 19);
+
path.lineTo(6.002, 19);
+
path.cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15);
+
path.lineTo(2.002, 7);
+
path.cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3);
+
path.lineTo(18.002, 3);
+
path.cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7);
+
path.lineTo(22.002, 15);
+
path.close();
+
} else {
+
// Outline reply icon path from Bluesky
+
// M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1
+
// v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4
+
// h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7
+
// a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z
+
+
// Inner shape
+
path.moveTo(20.002, 7);
+
path.cubicTo(20.002, 5.895, 19.107, 5, 18.002, 5);
+
path.lineTo(6.002, 5);
+
path.cubicTo(4.897, 5, 4.002, 5.895, 4.002, 7);
+
path.lineTo(4.002, 15);
+
path.cubicTo(4.002, 16.105, 4.897, 17, 6.002, 17);
+
path.lineTo(8.002, 17);
+
path.cubicTo(8.554, 17, 9.002, 17.448, 9.002, 18);
+
path.lineTo(9.002, 19.918);
+
path.lineTo(12.377, 17.218);
+
path.cubicTo(12.574, 17.073, 12.813, 17, 13.002, 17);
+
path.lineTo(18.002, 17);
+
path.cubicTo(19.107, 17, 20.002, 16.105, 20.002, 15);
+
path.lineTo(20.002, 7);
+
path.close();
+
+
// Outer shape
+
path.moveTo(22.002, 15);
+
path.cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19);
+
path.lineTo(13.354, 19);
+
path.lineTo(8.627, 22.781);
+
path.cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318);
+
path.lineTo(7.002, 22);
+
path.lineTo(7.002, 19);
+
path.lineTo(6.002, 19);
+
path.cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15);
+
path.lineTo(2.002, 7);
+
path.cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3);
+
path.lineTo(18.002, 3);
+
path.cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7);
+
path.lineTo(22.002, 15);
+
path.close();
+
}
+
+
canvas.drawPath(path, paint);
+
}
+
+
@override
+
bool shouldRepaint(_ReplyIconPainter oldDelegate) {
+
return oldDelegate.color != color || oldDelegate.filled != filled;
+
}
+
}
+94
lib/widgets/icons/share_icon.dart
···
···
+
import 'package:flutter/material.dart';
+
+
/// Share icon widget (arrow out of box)
+
///
+
/// Arrow-out-of-box icon from Bluesky's design system.
+
/// Uses the modified version with rounded corners for a friendlier look.
+
class ShareIcon extends StatelessWidget {
+
const ShareIcon({this.size = 18, this.color, super.key});
+
+
final double size;
+
final Color? color;
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
+
return CustomPaint(
+
size: Size(size, size),
+
painter: _ShareIconPainter(color: effectiveColor),
+
);
+
}
+
}
+
+
/// Custom painter for share icon
+
///
+
/// SVG path data from Bluesky's ArrowOutOfBoxModified icon component
+
class _ShareIconPainter extends CustomPainter {
+
_ShareIconPainter({required this.color});
+
+
final Color color;
+
+
@override
+
void paint(Canvas canvas, Size size) {
+
final paint = Paint()
+
..color = color
+
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
+
+
// Scale factor to fit 24x24 viewBox into widget size
+
final scale = size.width / 24.0;
+
canvas.scale(scale);
+
+
final path = Path();
+
+
// ArrowOutOfBoxModified_Stroke2_Corner2_Rounded path from Bluesky
+
// M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18
+
// a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293
+
// l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414
+
// L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z
+
+
// Box bottom part
+
path.moveTo(20, 13.75);
+
path.cubicTo(20.552, 13.75, 21, 14.198, 21, 14.75);
+
path.lineTo(21, 18);
+
path.cubicTo(21, 19.657, 19.657, 21, 18, 21);
+
path.lineTo(6, 21);
+
path.cubicTo(4.343, 21, 3, 19.657, 3, 18);
+
path.lineTo(3, 14.75);
+
path.cubicTo(3, 14.198, 3.448, 13.75, 4, 13.75);
+
path.cubicTo(4.552, 13.75, 5, 14.198, 5, 14.75);
+
path.lineTo(5, 18);
+
path.cubicTo(5, 18.552, 5.448, 19, 6, 19);
+
path.lineTo(18, 19);
+
path.cubicTo(18.552, 19, 19, 18.552, 19, 18);
+
path.lineTo(19, 14.75);
+
path.cubicTo(19, 14.198, 19.448, 13.75, 20, 13.75);
+
path.close();
+
+
// Arrow
+
path.moveTo(12, 3);
+
path.cubicTo(12.265, 3, 12.52, 3.105, 12.707, 3.293);
+
path.lineTo(17.207, 7.793);
+
path.cubicTo(17.598, 8.184, 17.598, 8.817, 17.207, 9.207);
+
path.cubicTo(16.816, 9.598, 16.183, 9.598, 15.793, 9.207);
+
path.lineTo(13, 6.414);
+
path.lineTo(13, 15.25);
+
path.cubicTo(13, 15.802, 12.552, 16.25, 12, 16.25);
+
path.cubicTo(11.448, 16.25, 11, 15.802, 11, 15.25);
+
path.lineTo(11, 6.414);
+
path.lineTo(8.207, 9.207);
+
path.cubicTo(7.816, 9.598, 7.183, 9.598, 6.793, 9.207);
+
path.cubicTo(6.402, 8.816, 6.402, 8.183, 6.793, 7.793);
+
path.lineTo(11.293, 3.293);
+
path.cubicTo(11.48, 3.105, 11.735, 3, 12, 3);
+
path.close();
+
+
canvas.drawPath(path, paint);
+
}
+
+
@override
+
bool shouldRepaint(_ShareIconPainter oldDelegate) {
+
return oldDelegate.color != color;
+
}
+
}
+35 -23
lib/widgets/post_card.dart
···
import '../constants/app_colors.dart';
import '../models/post.dart';
import '../utils/date_time_utils.dart';
/// Post card widget for displaying feed posts
///
···
/// time-ago calculations, enabling:
/// - Periodic updates of time strings
/// - Deterministic testing without DateTime.now()
-
class PostCard extends StatelessWidget {
const PostCard({required this.post, this.currentTime, super.key});
final FeedViewPost post;
final DateTime? currentTime;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
···
),
child: Center(
child: Text(
-
post.post.community.name[0].toUpperCase(),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 12,
···
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
-
'c/${post.post.community.name}',
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
···
),
),
Text(
-
'@${post.post.author.handle}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
···
// Time ago
Text(
DateTimeUtils.formatTimeAgo(
-
post.post.createdAt,
-
currentTime: currentTime,
),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.5),
···
const SizedBox(height: 8),
// Post title
-
if (post.post.title != null) ...[
Text(
-
post.post.title!,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
···
],
// Spacing after title (only if we have content below)
-
if (post.post.title != null &&
-
(post.post.embed?.external != null ||
-
post.post.text.isNotEmpty))
const SizedBox(height: 8),
// Embed (link preview)
-
if (post.post.embed?.external != null) ...[
-
_EmbedCard(embed: post.post.embed!.external!),
const SizedBox(height: 8),
],
// Post text body preview
-
if (post.post.text.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
···
borderRadius: BorderRadius.circular(8),
),
child: Text(
-
post.post.text,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.7),
fontSize: 13,
···
horizontal: 12,
vertical: 10,
),
-
child: Icon(
-
Icons.ios_share,
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
-
Icon(
-
Icons.chat_bubble_outline,
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
const SizedBox(width: 5),
Text(
DateTimeUtils.formatCount(
-
post.post.stats.commentCount,
),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.6),
···
// Heart button
InkWell(
onTap: () {
// TODO: Handle upvote/like interaction with backend
if (kDebugMode) {
debugPrint('Heart button tapped for post');
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
-
Icon(
-
Icons.favorite_border,
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
const SizedBox(width: 5),
Text(
-
DateTimeUtils.formatCount(post.post.stats.score),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.6),
fontSize: 13,
···
import '../constants/app_colors.dart';
import '../models/post.dart';
import '../utils/date_time_utils.dart';
+
import 'icons/animated_heart_icon.dart';
+
import 'icons/reply_icon.dart';
+
import 'icons/share_icon.dart';
/// Post card widget for displaying feed posts
///
···
/// time-ago calculations, enabling:
/// - Periodic updates of time strings
/// - Deterministic testing without DateTime.now()
+
class PostCard extends StatefulWidget {
const PostCard({required this.post, this.currentTime, super.key});
final FeedViewPost post;
final DateTime? currentTime;
@override
+
State<PostCard> createState() => _PostCardState();
+
}
+
+
class _PostCardState extends State<PostCard> {
+
bool _isLiked = false;
+
+
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
···
),
child: Center(
child: Text(
+
widget.post.post.community.name[0].toUpperCase(),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 12,
···
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
+
'c/${widget.post.post.community.name}',
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
···
),
),
Text(
+
'@${widget.post.post.author.handle}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
···
// Time ago
Text(
DateTimeUtils.formatTimeAgo(
+
widget.post.post.createdAt,
+
currentTime: widget.currentTime,
),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.5),
···
const SizedBox(height: 8),
// Post title
+
if (widget.post.post.title != null) ...[
Text(
+
widget.post.post.title!,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
···
],
// Spacing after title (only if we have content below)
+
if (widget.post.post.title != null &&
+
(widget.post.post.embed?.external != null ||
+
widget.post.post.text.isNotEmpty))
const SizedBox(height: 8),
// Embed (link preview)
+
if (widget.post.post.embed?.external != null) ...[
+
_EmbedCard(embed: widget.post.post.embed!.external!),
const SizedBox(height: 8),
],
// Post text body preview
+
if (widget.post.post.text.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
···
borderRadius: BorderRadius.circular(8),
),
child: Text(
+
widget.post.post.text,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.7),
fontSize: 13,
···
horizontal: 12,
vertical: 10,
),
+
child: ShareIcon(
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
+
ReplyIcon(
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
const SizedBox(width: 5),
Text(
DateTimeUtils.formatCount(
+
widget.post.post.stats.commentCount,
),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.6),
···
// Heart button
InkWell(
onTap: () {
+
setState(() {
+
_isLiked = !_isLiked;
+
});
// TODO: Handle upvote/like interaction with backend
if (kDebugMode) {
debugPrint('Heart button tapped for post');
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
+
AnimatedHeartIcon(
+
isLiked: _isLiked,
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
+
likedColor: const Color(0xFFFF0033), // Bright red
),
const SizedBox(width: 5),
Text(
+
DateTimeUtils.formatCount(widget.post.post.stats.score),
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.6),
fontSize: 13,