825 lines
28 KiB
Dart
825 lines
28 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:timeago/timeago.dart' as timeago;
|
|
import 'dart:ui';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../routes/app_routes.dart';
|
|
import '../models/post.dart';
|
|
import '../models/profile.dart';
|
|
import '../models/thread_node.dart';
|
|
import '../providers/api_provider.dart';
|
|
import '../services/auth_service.dart';
|
|
import '../theme/app_theme.dart';
|
|
import '../theme/tokens.dart';
|
|
import '../widgets/media/signed_media_image.dart';
|
|
import '../widgets/reactions/reactions_display.dart';
|
|
import '../widgets/reactions/reaction_picker.dart';
|
|
import '../widgets/modals/sanctuary_sheet.dart';
|
|
import '../widgets/sojorn_snackbar.dart';
|
|
import '../providers/notification_provider.dart';
|
|
import 'post/post_body.dart';
|
|
import 'post/post_view_mode.dart';
|
|
|
|
class TraditionalQuipsSheet extends ConsumerStatefulWidget {
|
|
final String postId;
|
|
final int initialQuipCount;
|
|
final VoidCallback? onQuipPosted;
|
|
|
|
const TraditionalQuipsSheet({
|
|
super.key,
|
|
required this.postId,
|
|
this.initialQuipCount = 0,
|
|
this.onQuipPosted,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<TraditionalQuipsSheet> createState() => _TraditionalQuipsSheetState();
|
|
}
|
|
|
|
class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
|
List<Post> _allPosts = [];
|
|
ThreadNode? _rootNode;
|
|
Post? _videoPost;
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
|
|
final TextEditingController _commentController = TextEditingController();
|
|
final FocusNode _commentFocus = FocusNode();
|
|
bool _isPosting = false;
|
|
|
|
// Replying state
|
|
ThreadNode? _replyingToNode;
|
|
|
|
// Selection mode for bulk delete
|
|
bool _isSelectionMode = false;
|
|
final Set<String> _selectedCommentIds = {};
|
|
|
|
// Collapsed state map: commentId -> isCollapsed
|
|
final Set<String> _collapsedIds = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_commentController.dispose();
|
|
_commentFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
|
|
// Load the video post to know the author (for pinning rights)
|
|
_videoPost = await api.getPostById(widget.postId);
|
|
|
|
// Load all comments in the thread
|
|
final posts = await api.getPostChain(widget.postId);
|
|
|
|
if (mounted) {
|
|
final tree = ThreadNode.buildTree(posts);
|
|
|
|
// Sort top-level comments: Pinned first, then by creation date
|
|
tree.children.sort((a, b) {
|
|
final aPinned = a.post.pinnedAt != null;
|
|
final bPinned = b.post.pinnedAt != null;
|
|
if (aPinned && !bPinned) return -1;
|
|
if (!aPinned && bPinned) return 1;
|
|
return a.post.createdAt.compareTo(b.post.createdAt);
|
|
});
|
|
|
|
setState(() {
|
|
_allPosts = posts;
|
|
_rootNode = tree;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _postComment() async {
|
|
final body = _commentController.text.trim();
|
|
if (body.isEmpty) return;
|
|
|
|
setState(() => _isPosting = true);
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
await api.publishPost(
|
|
body: body,
|
|
chainParentId: _replyingToNode?.post.id ?? widget.postId,
|
|
allowChain: true,
|
|
);
|
|
|
|
_commentController.clear();
|
|
_commentFocus.unfocus();
|
|
setState(() => _replyingToNode = null);
|
|
|
|
await _loadData();
|
|
widget.onQuipPosted?.call();
|
|
|
|
if (mounted) {
|
|
sojornSnackbar.showSuccess(context: context, message: 'Comment posted!');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
sojornSnackbar.showError(context: context, message: 'Failed to post: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isPosting = false);
|
|
}
|
|
}
|
|
|
|
void _startReply(ThreadNode node) {
|
|
setState(() {
|
|
_replyingToNode = node;
|
|
_commentFocus.requestFocus();
|
|
});
|
|
}
|
|
|
|
void _cancelReply() {
|
|
setState(() {
|
|
_replyingToNode = null;
|
|
_commentController.clear();
|
|
_commentFocus.unfocus();
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteComment(String commentId) async {
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
await api.deletePost(commentId);
|
|
await _loadData();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
sojornSnackbar.showError(context: context, message: 'Delete failed: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _bulkDelete() async {
|
|
if (_selectedCommentIds.isEmpty) return;
|
|
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: AppTheme.cardSurface,
|
|
title: Text('Delete Comments', style: TextStyle(color: AppTheme.navyBlue)),
|
|
content: Text('Are you sure you want to delete ${_selectedCommentIds.length} comments?', style: TextStyle(color: AppTheme.navyText)),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Delete', style: TextStyle(color: SojornColors.destructive)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirm != true) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
for (final id in _selectedCommentIds) {
|
|
await api.deletePost(id);
|
|
}
|
|
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedCommentIds.clear();
|
|
});
|
|
|
|
await _loadData();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
sojornSnackbar.showError(context: context, message: 'Bulk delete error: $e');
|
|
}
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _togglePin(Post comment) async {
|
|
final isPinned = comment.pinnedAt != null;
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
if (isPinned) {
|
|
await api.unpinPost(comment.id);
|
|
} else {
|
|
await api.pinPost(comment.id);
|
|
}
|
|
await _loadData();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
sojornSnackbar.showError(context: context, message: 'Pin action failed: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _toggleCollapse(String id) {
|
|
setState(() {
|
|
if (_collapsedIds.contains(id)) {
|
|
_collapsedIds.remove(id);
|
|
} else {
|
|
_collapsedIds.add(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _toggleSelection(String id) {
|
|
setState(() {
|
|
if (_selectedCommentIds.contains(id)) {
|
|
_selectedCommentIds.remove(id);
|
|
if (_selectedCommentIds.isEmpty) _isSelectionMode = false;
|
|
} else {
|
|
_selectedCommentIds.add(id);
|
|
_isSelectionMode = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DraggableScrollableSheet(
|
|
initialChildSize: 0.95,
|
|
minChildSize: 0.6,
|
|
maxChildSize: 0.95,
|
|
snap: true,
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.scaffoldBg,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: SojornColors.overlayScrim,
|
|
blurRadius: 40,
|
|
offset: const Offset(0, -10),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildAppBarHeader(),
|
|
Expanded(
|
|
child: _isLoading && _allPosts.isEmpty
|
|
? Center(child: CircularProgressIndicator(color: AppTheme.brightNavy))
|
|
: _buildCommentList(scrollController),
|
|
),
|
|
if (!_isSelectionMode) _buildInputArea(),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBarHeader() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.cardSurface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
|
|
width: 1
|
|
),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Drag Handle
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(top: 12, bottom: 4),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
|
child: Row(
|
|
children: [
|
|
if (!_isSelectionMode) ...[
|
|
IconButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Thread',
|
|
style: GoogleFonts.inter(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 18,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
onPressed: () => context.go(AppRoutes.homeAlias),
|
|
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
|
|
),
|
|
IconButton(
|
|
onPressed: () {}, // Search - to be implemented or consistent with ThreadedConversationScreen
|
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
|
),
|
|
IconButton(
|
|
onPressed: () => context.go(AppRoutes.secureChat),
|
|
icon: Consumer(
|
|
builder: (context, ref, child) {
|
|
final badge = ref.watch(currentBadgeProvider);
|
|
return Badge(
|
|
label: Text(badge.messageCount.toString()),
|
|
isLabelVisible: badge.messageCount > 0,
|
|
backgroundColor: AppTheme.brightNavy,
|
|
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
] else ...[
|
|
IconButton(
|
|
onPressed: () => setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedCommentIds.clear();
|
|
}),
|
|
icon: Icon(Icons.close, color: AppTheme.navyBlue),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${_selectedCommentIds.length} Selected',
|
|
style: GoogleFonts.inter(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 18,
|
|
color: AppTheme.brightNavy,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: SojornColors.destructive),
|
|
onPressed: _bulkDelete,
|
|
),
|
|
]
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
int get _commentCount => _rootNode?.totalCount ?? widget.initialQuipCount;
|
|
|
|
Widget _buildCommentList(ScrollController scrollController) {
|
|
if (_error != null) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: SojornColors.destructive, size: 48),
|
|
const SizedBox(height: 16),
|
|
Text('Error: $_error', style: TextStyle(color: AppTheme.navyText), textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_rootNode == null || _rootNode!.children.isEmpty) {
|
|
if (_isLoading) return Center(child: CircularProgressIndicator(color: AppTheme.brightNavy));
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.chat_bubble_outline, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No messages yet',
|
|
style: GoogleFonts.inter(color: AppTheme.textSecondary.withValues(alpha: 0.5), fontSize: 16),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final items = _flattenTree(_rootNode!.children);
|
|
|
|
return ListView.builder(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final node = items[index];
|
|
return _CommentTile(
|
|
node: node,
|
|
videoAuthorId: _videoPost?.authorId,
|
|
isPinned: node.post.pinnedAt != null,
|
|
isSelected: _selectedCommentIds.contains(node.post.id),
|
|
isCollapsed: _collapsedIds.contains(node.post.id),
|
|
onCollapse: () => _toggleCollapse(node.post.id),
|
|
onSelect: () => _toggleSelection(node.post.id),
|
|
onReply: () => _startReply(node),
|
|
onDelete: () => _deleteComment(node.post.id),
|
|
onPin: () => _togglePin(node.post),
|
|
onReport: () => SanctuarySheet.show(context, node.post),
|
|
onReaction: (emoji) => _toggleReaction(node.post.id, emoji),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<ThreadNode> _flattenTree(List<ThreadNode> nodes) {
|
|
List<ThreadNode> flat = [];
|
|
for (final node in nodes) {
|
|
flat.add(node);
|
|
if (!_collapsedIds.contains(node.post.id)) {
|
|
flat.addAll(_flattenTree(node.children));
|
|
}
|
|
}
|
|
return flat;
|
|
}
|
|
|
|
Future<void> _toggleReaction(String postId, String emoji) async {
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
await api.toggleReaction(postId, emoji);
|
|
await _loadData();
|
|
} catch (e) {
|
|
if (mounted) sojornSnackbar.showError(context: context, message: 'Reaction failed: $e');
|
|
}
|
|
}
|
|
|
|
Widget _buildInputArea() {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_replyingToNode != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
color: AppTheme.brightNavy.withValues(alpha: 0.05),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.reply, size: 14, color: AppTheme.brightNavy),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Replying to @${_replyingToNode!.post.author?.handle}',
|
|
style: TextStyle(color: AppTheme.navyBlue, fontSize: 13, fontWeight: FontWeight.w600),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: _cancelReply,
|
|
child: Icon(Icons.close, size: 14, color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.of(context).viewInsets.bottom + 16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.cardSurface,
|
|
border: Border(top: BorderSide(color: AppTheme.egyptianBlue.withValues(alpha: 0.1))),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.scaffoldBg,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppTheme.egyptianBlue.withValues(alpha: 0.2)),
|
|
),
|
|
child: TextField(
|
|
controller: _commentController,
|
|
focusNode: _commentFocus,
|
|
style: TextStyle(color: AppTheme.textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: _replyingToNode != null ? 'Type your reply...' : 'Add a comment...',
|
|
hintStyle: TextStyle(color: AppTheme.textSecondary.withValues(alpha: 0.5)),
|
|
border: InputBorder.none,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
GestureDetector(
|
|
onTap: _isPosting ? null : () => _postComment(),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.brightNavy,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppTheme.brightNavy.withValues(alpha: 0.2),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: _isPosting
|
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
|
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 20),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CommentTile extends StatelessWidget {
|
|
final ThreadNode node;
|
|
final String? videoAuthorId;
|
|
final bool isPinned;
|
|
final bool isSelected;
|
|
final bool isCollapsed;
|
|
final VoidCallback onCollapse;
|
|
final VoidCallback onSelect;
|
|
final VoidCallback onReply;
|
|
final VoidCallback onDelete;
|
|
final VoidCallback onPin;
|
|
final VoidCallback onReport;
|
|
final Function(String) onReaction;
|
|
|
|
const _CommentTile({
|
|
required this.node,
|
|
this.videoAuthorId,
|
|
this.isPinned = false,
|
|
this.isSelected = false,
|
|
this.isCollapsed = false,
|
|
required this.onCollapse,
|
|
required this.onSelect,
|
|
required this.onReply,
|
|
required this.onDelete,
|
|
required this.onPin,
|
|
required this.onReport,
|
|
required this.onReaction,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentUserId = AuthService.instance.currentUser?.id;
|
|
final isMyComment = node.post.authorId == currentUserId;
|
|
final isVideoAuthor = videoAuthorId == currentUserId;
|
|
|
|
final double indent = (node.depth - 1) * 24.0;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(left: 16 + indent, right: 16, bottom: 16),
|
|
child: GestureDetector(
|
|
onLongPress: onSelect,
|
|
onTap: isSelected ? onSelect : null,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? AppTheme.brightNavy.withValues(alpha: 0.08) : AppTheme.cardSurface,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isSelected ? AppTheme.brightNavy.withValues(alpha: 0.3) : AppTheme.egyptianBlue.withValues(alpha: 0.1),
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
boxShadow: [
|
|
if (!isSelected)
|
|
BoxShadow(
|
|
color: SojornColors.basicBlack.withValues(alpha: 0.02),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildAvatar(node.post.author),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
node.post.author?.displayName ?? node.post.author?.handle ?? 'unknown',
|
|
style: GoogleFonts.inter(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 15,
|
|
color: AppTheme.textPrimary,
|
|
),
|
|
),
|
|
if (isPinned) ...[
|
|
const SizedBox(width: 8),
|
|
Icon(Icons.push_pin, size: 13, color: AppTheme.brightNavy),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Pinned',
|
|
style: GoogleFonts.inter(
|
|
fontSize: 12,
|
|
color: AppTheme.brightNavy,
|
|
fontWeight: FontWeight.w800,
|
|
)
|
|
),
|
|
],
|
|
const Spacer(),
|
|
_buildMenu(context, isMyComment, isVideoAuthor),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
PostBody(
|
|
text: node.post.body,
|
|
bodyFormat: node.post.bodyFormat,
|
|
backgroundId: node.post.backgroundId,
|
|
mode: PostViewMode.feed,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
timeago.format(node.post.createdAt, locale: 'en_short'),
|
|
style: TextStyle(color: AppTheme.textSecondary.withValues(alpha: 0.5), fontSize: 13),
|
|
),
|
|
const SizedBox(width: 24),
|
|
GestureDetector(
|
|
onTap: onReply,
|
|
child: Text(
|
|
'Reply',
|
|
style: TextStyle(color: AppTheme.brightNavy, fontSize: 13, fontWeight: FontWeight.w800)
|
|
),
|
|
),
|
|
if (node.hasChildren) ...[
|
|
const SizedBox(width: 24),
|
|
GestureDetector(
|
|
onTap: onCollapse,
|
|
child: Text(
|
|
isCollapsed ? 'Show replies (${node.totalDescendants})' : 'Hide replies',
|
|
style: TextStyle(color: AppTheme.egyptianBlue, fontSize: 13, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (node.post.reactions != null && node.post.reactions!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 14),
|
|
child: ReactionsDisplay(
|
|
reactionCounts: node.post.reactions!,
|
|
myReactions: node.post.myReactions?.toSet() ?? {},
|
|
onToggleReaction: onReaction,
|
|
onAddReaction: () => _showReactionPicker(context),
|
|
mode: ReactionsDisplayMode.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(
|
|
node.post.isLiked == true ? Icons.favorite : Icons.favorite_border,
|
|
size: 20,
|
|
color: node.post.isLiked == true ? SojornColors.destructive : AppTheme.textSecondary.withValues(alpha: 0.2),
|
|
),
|
|
onPressed: () => onReaction('❤️'),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
if (node.post.likeCount != null && node.post.likeCount! > 0)
|
|
Text(
|
|
'${node.post.likeCount}',
|
|
style: TextStyle(color: AppTheme.textSecondary.withValues(alpha: 0.6), fontSize: 13, fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAvatar(Profile? author) {
|
|
return Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.navyBlue.withValues(alpha: 0.05),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppTheme.egyptianBlue.withValues(alpha: 0.1)),
|
|
),
|
|
child: author?.avatarUrl != null && author!.avatarUrl!.isNotEmpty
|
|
? ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: SignedMediaImage(url: author.avatarUrl!, width: 40, height: 40, fit: BoxFit.cover),
|
|
)
|
|
: Center(child: Text(author?.displayName?.isNotEmpty == true ? author!.displayName![0].toUpperCase() : '?', style: TextStyle(color: AppTheme.navyBlue, fontSize: 16, fontWeight: FontWeight.w800))),
|
|
);
|
|
}
|
|
|
|
Widget _buildMenu(BuildContext context, bool isMyComment, bool isVideoAuthor) {
|
|
return PopupMenuButton<String>(
|
|
icon: Icon(Icons.more_horiz, size: 22, color: AppTheme.textSecondary.withValues(alpha: 0.4)),
|
|
padding: EdgeInsets.zero,
|
|
color: AppTheme.cardSurface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(color: AppTheme.egyptianBlue.withValues(alpha: 0.1)),
|
|
),
|
|
itemBuilder: (context) => [
|
|
if (isVideoAuthor)
|
|
PopupMenuItem(
|
|
value: 'pin',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.push_pin_outlined, size: 18, color: AppTheme.navyBlue),
|
|
const SizedBox(width: 12),
|
|
Text(isPinned ? 'Unpin' : 'Pin', style: TextStyle(color: AppTheme.textPrimary)),
|
|
],
|
|
)
|
|
),
|
|
if (isMyComment || isVideoAuthor)
|
|
PopupMenuItem(
|
|
value: 'delete',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.delete_outline, size: 18, color: SojornColors.destructive),
|
|
const SizedBox(width: 12),
|
|
const Text('Delete', style: TextStyle(color: SojornColors.destructive)),
|
|
],
|
|
)
|
|
),
|
|
PopupMenuItem(
|
|
value: 'report',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.flag_outlined, size: 18, color: AppTheme.navyBlue),
|
|
const SizedBox(width: 12),
|
|
Text('Report', style: TextStyle(color: AppTheme.textPrimary)),
|
|
],
|
|
)
|
|
),
|
|
PopupMenuItem(
|
|
value: 'select',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.check_circle_outline, size: 18, color: AppTheme.navyBlue),
|
|
const SizedBox(width: 12),
|
|
Text('Select multiple', style: TextStyle(color: AppTheme.textPrimary)),
|
|
],
|
|
)
|
|
),
|
|
],
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case 'pin': onPin(); break;
|
|
case 'delete': onDelete(); break;
|
|
case 'report': onReport(); break;
|
|
case 'select': onSelect(); break;
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showReactionPicker(BuildContext context) {
|
|
final Map<String, int> counts = node.post.reactions ?? {};
|
|
final Set<String> mine = node.post.myReactions?.toSet() ?? {};
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => ReactionPicker(
|
|
onReactionSelected: onReaction,
|
|
reactions: const ['❤️', '👍', '😂', '😮', '😢', '😡', '🔥'],
|
|
reactionCounts: counts,
|
|
myReactions: mine,
|
|
),
|
|
);
|
|
}
|
|
}
|