sojorn/sojorn_app/lib/widgets/post/post_actions.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

210 lines
5.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import '../../models/post.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../sojorn_snackbar.dart';
/// Post actions with a vibrant, clear, and energetic design.
///
/// Design Intent:
/// - Actions are clear, tappable, and visually engaging.
/// - Clear state changes: default (energetic) → active (highlighted).
class PostActions extends ConsumerStatefulWidget {
final Post post;
final VoidCallback? onChain;
final VoidCallback? onPostChanged;
const PostActions({
super.key,
required this.post,
this.onChain,
this.onPostChanged,
});
@override
ConsumerState<PostActions> createState() => _PostActionsState();
}
class _PostActionsState extends ConsumerState<PostActions> {
late bool _isLiked;
late bool _isSaved;
bool _isLiking = false;
bool _isSaving = false;
@override
void initState() {
super.initState();
_isLiked = widget.post.isLiked ?? false;
_isSaved = widget.post.isSaved ?? false;
}
void _showError(String message) {
sojornSnackbar.showError(
context: context,
message: message,
);
}
Future<void> _toggleLike() async {
if (_isLiking) return;
setState(() {
_isLiking = true;
_isLiked = !_isLiked;
});
final apiService = ref.read(apiServiceProvider);
try {
if (_isLiked) {
await apiService.appreciatePost(widget.post.id);
} else {
await apiService.unappreciatePost(widget.post.id);
}
} catch (e) {
if (mounted) {
setState(() {
_isLiked = !_isLiked;
});
_showError(e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) {
setState(() {
_isLiking = false;
});
}
}
}
Future<void> _toggleSave() async {
if (_isSaving) return;
setState(() {
_isSaving = true;
_isSaved = !_isSaved;
});
final apiService = ref.read(apiServiceProvider);
try {
if (_isSaved) {
await apiService.savePost(widget.post.id);
} else {
await apiService.unsavePost(widget.post.id);
}
} catch (e) {
if (mounted) {
setState(() {
_isSaved = !_isSaved;
});
_showError(e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
Future<void> _sharePost() async {
final handle = widget.post.author?.handle ?? 'sojorn';
final text = '${widget.post.body}\n\n— @$handle on sojorn';
try {
await Share.share(text);
} catch (e) {
_showError('Unable to share right now.');
}
}
@override
Widget build(BuildContext context) {
final allowChain = widget.post.allowChain && widget.post.visibility != 'private' && widget.onChain != null;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Icon-only action buttons with Row layout
_IconActionButton(
icon: Icons.favorite_border,
activeIcon: Icons.favorite,
isActive: _isLiked,
isLoading: _isLiking,
onPressed: _isLiking ? null : _toggleLike,
activeColor: AppTheme.brightNavy,
),
const SizedBox(width: 24),
_IconActionButton(
icon: Icons.bookmark_border,
activeIcon: Icons.bookmark,
isActive: _isSaved,
isLoading: _isSaving,
onPressed: _isSaving ? null : _toggleSave,
activeColor: AppTheme.brightNavy,
),
const SizedBox(width: 24),
_IconActionButton(
icon: Icons.share_outlined,
onPressed: _sharePost,
),
const Spacer(),
if (allowChain)
_IconActionButton(
icon: Icons.reply,
onPressed: widget.onChain,
),
],
);
}
}
/// Icon-only action button with large touch target.
class _IconActionButton extends StatelessWidget {
final IconData icon;
final IconData? activeIcon;
final VoidCallback? onPressed;
final bool isActive;
final bool isLoading;
final Color? activeColor;
const _IconActionButton({
required this.icon,
this.activeIcon,
this.onPressed,
this.isActive = false,
this.isLoading = false,
this.activeColor,
});
@override
Widget build(BuildContext context) {
final effectiveActiveColor = activeColor ?? AppTheme.brightNavy;
final effectiveDefaultColor = AppTheme.royalPurple;
final color = isActive ? effectiveActiveColor : effectiveDefaultColor;
final displayIcon = isActive && activeIcon != null ? activeIcon! : icon;
return AnimatedOpacity(
opacity: isLoading ? 0.5 : 1.0,
duration: const Duration(milliseconds: 200),
child: IconButton(
onPressed: onPressed,
iconSize: 22.0,
padding: const EdgeInsets.all(8.0),
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
icon: Icon(
displayIcon,
size: 22.0,
color: color,
),
),
);
}
}