feat: implement traditional threaded quips (comments) sheet with rich text post body and expand functionality.
This commit is contained in:
parent
933161cb65
commit
0f6a91e319
|
|
@ -15,6 +15,8 @@ import '../../widgets/media/signed_media_image.dart';
|
|||
import '../compose/compose_screen.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../secure_chat/secure_chat_full_screen.dart';
|
||||
import '../../widgets/post/post_body.dart';
|
||||
import '../../widgets/post/post_view_mode.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -554,14 +556,11 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
}
|
||||
|
||||
Widget _buildStageContent(Post post) {
|
||||
return Text(
|
||||
post.body,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
color: AppTheme.navyText,
|
||||
height: 1.7,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
return PostBody(
|
||||
text: post.body,
|
||||
bodyFormat: post.bodyFormat,
|
||||
backgroundId: post.backgroundId,
|
||||
mode: PostViewMode.detail,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../../../theme/app_theme.dart';
|
|||
import '../../post/post_detail_screen.dart';
|
||||
import 'quip_video_item.dart';
|
||||
import '../../home/home_shell.dart';
|
||||
import '../../../widgets/video_comments_sheet.dart';
|
||||
|
||||
class Quip {
|
||||
final String id;
|
||||
|
|
@ -403,23 +404,18 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
}
|
||||
|
||||
Future<void> _openComments(Quip quip) async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
try {
|
||||
final post = await api.getPostById(quip.id);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => VideoCommentsSheet(
|
||||
postId: quip.id,
|
||||
initialCommentCount: 0,
|
||||
onCommentPosted: () {
|
||||
// Optional: handle reload if needed
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not load comments right now.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareQuip(Quip quip) {
|
||||
|
|
|
|||
|
|
@ -54,12 +54,14 @@ class PostBody extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cleanedText = text.replaceAll(RegExp(r'\s+$'), '');
|
||||
// Detect if this is Markdown content
|
||||
final isMarkdown =
|
||||
bodyFormat == 'markdown' || _hasMarkdownSyntax(cleanedText);
|
||||
// 1. Trim trailing whitespace
|
||||
// 2. Collapse 3+ newlines into 2 (max one empty line)
|
||||
final cleanedText = text
|
||||
.replaceAll(RegExp(r'\s+$'), '')
|
||||
.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
|
||||
final isMarkdown = bodyFormat == 'markdown' || _hasMarkdownSyntax(cleanedText);
|
||||
|
||||
// Choose typography based on content length
|
||||
final TextStyle style;
|
||||
if (isReflective) {
|
||||
style = AppTheme.postBodyReflective;
|
||||
|
|
@ -74,18 +76,90 @@ class PostBody extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
final int? maxLines = _maxLines;
|
||||
|
||||
// If we have a maxLines limit, we want to show "Expand post..." if it's exceeded
|
||||
if (maxLines != null) {
|
||||
// Approximate line height (fontSize * 1.5 height + a bit of buffer)
|
||||
final double lineHeight = (style.fontSize ?? 17.0) * 1.6;
|
||||
final double maxHeight = maxLines * lineHeight;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final content = isMarkdown
|
||||
? MarkdownPostBody(
|
||||
markdown: text,
|
||||
baseStyle: style,
|
||||
// We don't pass maxLines to MarkdownPostBody here because we handle clipping ourselves
|
||||
// to show the "Expand" button at the bottom correctly
|
||||
)
|
||||
: sojornRichText(
|
||||
text: cleanedText,
|
||||
style: style,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: ClipRect(
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
_ExpandIndicator(maxLines: maxLines, text: text),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isMarkdown) {
|
||||
return MarkdownPostBody(
|
||||
markdown: text,
|
||||
baseStyle: style,
|
||||
maxLines: _maxLines,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
}
|
||||
|
||||
return sojornRichText(
|
||||
text: cleanedText,
|
||||
style: style,
|
||||
maxLines: _maxLines,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandIndicator extends StatelessWidget {
|
||||
final int maxLines;
|
||||
final String text;
|
||||
|
||||
const _ExpandIndicator({required this.maxLines, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Basic heuristic: check if line count is high or text is long
|
||||
final lineCount = '\n'.allMatches(text).length + 1;
|
||||
final isLong = text.length > 400 || lineCount > maxLines;
|
||||
|
||||
if (!isLong) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Expand post...',
|
||||
style: TextStyle(
|
||||
color: AppTheme.brightNavy,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.keyboard_arrow_down, size: 16, color: AppTheme.brightNavy),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
812
sojorn_app/lib/widgets/traditional_quips_sheet.dart
Normal file
812
sojorn_app/lib/widgets/traditional_quips_sheet.dart
Normal file
|
|
@ -0,0 +1,812 @@
|
|||
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 '../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 '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: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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: Colors.black.withOpacity(0.1),
|
||||
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.withOpacity(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.withOpacity(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: 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: Colors.redAccent),
|
||||
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: Colors.redAccent, 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.withOpacity(0.1)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: GoogleFonts.inter(color: AppTheme.textSecondary.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _isPosting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send, color: Colors.white, 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.withOpacity(0.08) : AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppTheme.brightNavy.withOpacity(0.3) : AppTheme.egyptianBlue.withOpacity(0.1),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: [
|
||||
if (!isSelected)
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(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.withOpacity(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 ? Colors.redAccent : AppTheme.textSecondary.withOpacity(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.withOpacity(0.6), fontSize: 13, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(Profile? author) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(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.withOpacity(0.4)),
|
||||
padding: EdgeInsets.zero,
|
||||
color: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.egyptianBlue.withOpacity(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: Colors.redAccent),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Delete', style: TextStyle(color: Colors.redAccent)),
|
||||
],
|
||||
)
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import '../models/post.dart';
|
|||
import '../models/thread_node.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/glassmorphic_quips_sheet.dart';
|
||||
import '../widgets/traditional_quips_sheet.dart';
|
||||
|
||||
/// Glassmorphic video comments sheet with kinetic navigation
|
||||
/// Traditional video comments sheet (TikTok/Reddit style)
|
||||
class VideoCommentsSheet extends StatefulWidget {
|
||||
final String postId;
|
||||
final int initialCommentCount;
|
||||
|
|
@ -25,22 +25,9 @@ class VideoCommentsSheet extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _VideoCommentsSheetState extends State<VideoCommentsSheet> {
|
||||
// This is now just a wrapper around GlassmorphicQuipsSheet
|
||||
// All functionality is delegated to the glassmorphic sheet
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassmorphicQuipsSheet(
|
||||
return TraditionalQuipsSheet(
|
||||
postId: widget.postId,
|
||||
initialQuipCount: widget.initialCommentCount,
|
||||
onQuipPosted: widget.onCommentPosted,
|
||||
|
|
|
|||
Loading…
Reference in a new issue