sojorn/sojorn_app/lib/widgets/reading_post_card.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

328 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../models/post.dart';
import '../theme/app_theme.dart';
import 'media/signed_media_image.dart';
class ReadingPostCard extends StatefulWidget {
final Post post;
final VoidCallback? onTap;
final VoidCallback? onAppreciate;
final VoidCallback? onSave;
final bool isAppreciated;
final bool isSaved;
final bool showDivider;
const ReadingPostCard({
super.key,
required this.post,
this.onTap,
this.onAppreciate,
this.onSave,
this.isAppreciated = false,
this.isSaved = false,
this.showDivider = true,
});
@override
State<ReadingPostCard> createState() => _ReadingPostCardState();
}
class _ReadingPostCardState extends State<ReadingPostCard> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPostCard(),
if (widget.showDivider) _buildDivider(),
],
);
}
Widget _buildPostCard() {
return Container(
constraints: const BoxConstraints(maxWidth: 680),
margin: _getMargin(),
decoration: BoxDecoration(
color: AppTheme.white,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border(
left: BorderSide(
color: _isPressed ? AppTheme.brightNavy : AppTheme.egyptianBlue,
width: AppTheme.flowLineWidth,
),
right: BorderSide(color: AppTheme.egyptianBlue, width: 1),
top: BorderSide(color: AppTheme.egyptianBlue, width: 1),
bottom: BorderSide(color: AppTheme.egyptianBlue, width: 1),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
splashColor: AppTheme.queenPink.withValues(alpha: 0.3),
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppTheme.spacingMd,
AppTheme.spacingSm,
AppTheme.spacingLg,
AppTheme.spacingMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAuthorRow(),
const SizedBox(height: AppTheme.spacingMd),
_buildBodyText(),
const SizedBox(height: AppTheme.spacingLg),
_buildActionRow(),
],
),
),
),
),
);
}
Widget _buildDivider() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd),
child: Container(
height: AppTheme.dividerThickness,
decoration: BoxDecoration(color: AppTheme.egyptianBlue),
),
);
}
EdgeInsets _getMargin() {
final charCount = widget.post.body.length;
if (charCount < 100) {
return EdgeInsets.only(
left: AppTheme.spacingMd,
right: AppTheme.spacingMd,
top: AppTheme.spacingPostShort);
} else if (charCount < 300) {
return EdgeInsets.only(
left: AppTheme.spacingMd,
right: AppTheme.spacingMd,
top: AppTheme.spacingPostMedium);
}
return EdgeInsets.only(
left: AppTheme.spacingMd,
right: AppTheme.spacingMd,
top: AppTheme.spacingPostLong);
}
Widget _buildAuthorRow() {
final avatarUrl = widget.post.author?.avatarUrl;
final handle = widget.post.author?.handle ?? '';
final fallbackColor = _getAvatarColor(handle);
return Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: fallbackColor,
borderRadius: BorderRadius.circular(10),
),
child: avatarUrl != null && avatarUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(9),
child: SignedMediaImage(
url: avatarUrl,
width: 36,
height: 36,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
handle.isNotEmpty ? handle[0].toUpperCase() : '?',
style: AppTheme.textTheme.labelMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: AppTheme.spacingSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
widget.post.author?.displayName ?? 'Unknown',
style: AppTheme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: AppTheme.navyBlue,
),
overflow: TextOverflow.ellipsis,
),
),
if (widget.post.author?.isOfficial == true) ...[
const SizedBox(width: AppTheme.spacingXs),
_buildOfficialBadge(),
],
if (widget.post.author?.isOfficial != true &&
widget.post.author?.trustState != null) ...[
const SizedBox(width: AppTheme.spacingXs),
_buildTrustBadge(),
],
],
),
const SizedBox(height: 2),
Text(
timeago.format(widget.post.createdAt),
style: AppTheme.textTheme.labelSmall
?.copyWith(color: AppTheme.egyptianBlue),
),
],
),
),
],
);
}
Widget _buildBodyText() {
final charCount = widget.post.body.length;
final style = charCount >= 10 ? AppTheme.postBodyLong : AppTheme.postBody;
return Text(widget.post.body, style: style);
}
Widget _buildActionRow() {
return Row(
children: [
if (widget.post.contentIntegrityScore < 1.0) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingSm, vertical: 2),
decoration: BoxDecoration(
color: _getCISColor(widget.post.contentIntegrityScore)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
),
child: Text(
'CIS ${(widget.post.contentIntegrityScore * 100).toInt()}%',
style: AppTheme.textTheme.labelSmall?.copyWith(
color: _getCISColor(widget.post.contentIntegrityScore),
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: AppTheme.spacingMd),
],
const Spacer(),
_buildActionButton(
icon: widget.isAppreciated ? Icons.favorite : Icons.favorite_border,
isActive: widget.isAppreciated,
onPressed: widget.onAppreciate,
color: AppTheme.brightNavy,
),
const SizedBox(width: AppTheme.spacingMd),
_buildActionButton(
icon: widget.isSaved ? Icons.bookmark : Icons.bookmark_border,
isActive: widget.isSaved,
onPressed: widget.onSave,
color: AppTheme.brightNavy,
),
],
);
}
Widget _buildActionButton({
required IconData icon,
required bool isActive,
VoidCallback? onPressed,
required Color color,
}) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingSm, vertical: AppTheme.spacingXs),
child: Icon(icon,
size: 18, color: isActive ? color : AppTheme.royalPurple),
),
);
}
Widget _buildOfficialBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.info.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
),
child: Text('sojorn',
style: AppTheme.textTheme.labelSmall?.copyWith(
fontSize: 8,
fontWeight: FontWeight.w600,
color: AppTheme.info,
letterSpacing: 0.4,
)),
);
}
Widget _buildTrustBadge() {
final tier = widget.post.author!.trustState!.tier;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _getTierColor(tier.value).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
),
child: Text(tier.displayName.toUpperCase(),
style: AppTheme.textTheme.labelSmall?.copyWith(
fontSize: 8,
fontWeight: FontWeight.w600,
color: _getTierColor(tier.value),
letterSpacing: 0.4,
)),
);
}
Color _getAvatarColor(String handle) {
final hash = handle.hashCode;
final hue = (hash % 360).toDouble();
return HSLColor.fromAHSL(1.0, hue, 0.45, 0.55).toColor();
}
Color _getTierColor(String tier) {
switch (tier) {
case 'established':
return AppTheme.tierEstablished;
case 'trusted':
return AppTheme.tierTrusted;
default:
return AppTheme.tierNew;
}
}
Color _getCISColor(double cis) {
if (cis >= 0.8) return AppTheme.success;
if (cis >= 0.6) return AppTheme.warning;
return AppTheme.error;
}
}