Chain updates

This commit is contained in:
Patrick Britton 2026-01-30 10:03:15 -06:00
parent b9351b76ae
commit ca59f3286a
6 changed files with 1756 additions and 562 deletions

View file

@ -5,10 +5,9 @@ import '../../models/post.dart';
import '../../models/thread_node.dart'; import '../../models/thread_node.dart';
import '../../providers/api_provider.dart'; import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/threaded_comment_widget.dart'; import '../../widgets/kinetic_thread_widget.dart';
import '../compose/compose_screen.dart';
/// Screen for displaying threaded conversations (Reddit-style) /// Screen for displaying threaded conversations with Kinetic Spatial Engine
class ThreadedConversationScreen extends ConsumerStatefulWidget { class ThreadedConversationScreen extends ConsumerStatefulWidget {
final String rootPostId; final String rootPostId;
final Post? rootPost; final Post? rootPost;
@ -161,164 +160,30 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadThread, onRefresh: _loadThread,
color: AppTheme.brightNavy, color: AppTheme.brightNavy,
child: SingleChildScrollView( child: _threadTree == null ? Center(
physics: const AlwaysScrollableScrollPhysics(), child: Text(
child: Column( 'Thread not found',
children: [ style: GoogleFonts.inter(
// Root post color: AppTheme.textSecondary,
ThreadedCommentWidget( fontSize: 16,
node: _threadTree!, ),
onReply: _handleReply,
onLike: _handleLike,
onNavigateToParent: _navigateToParent,
onNavigateToChild: _navigateToChild,
isRootPost: true,
),
// Thread statistics
_buildThreadStats(),
// Reply input area
_buildReplyInput(),
],
), ),
) : KineticThreadWidget(
rootNode: _threadTree!,
onLayerChanged: _handleLayerChange,
onReplyPosted: _loadThread,
), ),
); );
} }
Widget _buildThreadStats() { void _handleLayerChange(ThreadNode newFocusNode) {
if (_threadTree == null || !_threadTree!.hasChildren) { // Update app bar title to reflect current layer
return const SizedBox.shrink(); setState(() {});
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.brightNavy.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.chat_bubble_outline,
color: AppTheme.brightNavy,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thread Statistics',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 16,
),
),
Text(
'${_threadTree!.totalCount} total comments • ${_getMaxDepth()} levels deep',
style: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
],
),
),
],
),
);
}
Widget _buildReplyInput() {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Join the conversation',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 16,
),
),
const SizedBox(height: 12),
TextField(
maxLines: 3,
decoration: InputDecoration(
hintText: 'Write your reply...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.brightNavy),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _handleReply,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('Post Reply'),
),
),
],
),
);
}
int _getMaxDepth() {
if (_threadTree == null) return 0;
int maxDepth = 0;
void traverse(ThreadNode node, int currentDepth) {
maxDepth = currentDepth > maxDepth ? currentDepth : maxDepth;
for (final child in node.children) {
traverse(child, currentDepth + 1);
}
}
for (final child in _threadTree!.children) {
traverse(child, 1);
}
return maxDepth;
} }
void _handleReply() { void _handleReply() {
Navigator.of(context).push( // This is now handled inline by the KineticThreadWidget
MaterialPageRoute( // Keeping for compatibility
builder: (context) => ComposeScreen(
chainParentPost: widget.rootPost,
),
),
);
} }
void _handleLike() { void _handleLike() {
@ -330,26 +195,4 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
), ),
); );
} }
void _navigateToParent(ThreadNode parent) {
// Navigate to parent post in the thread
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigated to parent: ${parent.post.author?.displayName ?? 'Anonymous'}'),
backgroundColor: AppTheme.brightNavy,
),
);
// TODO: Implement actual navigation/scrolling to parent
}
void _navigateToChild(ThreadNode child) {
// Navigate to child post in the thread
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigated to child: ${child.post.author?.displayName ?? 'Anonymous'}'),
backgroundColor: AppTheme.brightNavy,
),
);
// TODO: Implement actual navigation/scrolling to child
}
} }

View file

@ -0,0 +1,907 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:ui';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../models/post.dart';
import '../models/thread_node.dart';
import '../providers/api_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/media/signed_media_image.dart';
import 'kinetic_thread_widget.dart';
/// Glassmorphic TikTok-style quips sheet with HUD design
class GlassmorphicQuipsSheet extends ConsumerStatefulWidget {
final String postId;
final int initialQuipCount;
final VoidCallback? onQuipPosted;
const GlassmorphicQuipsSheet({
super.key,
required this.postId,
this.initialQuipCount = 0,
this.onQuipPosted,
});
@override
ConsumerState<GlassmorphicQuipsSheet> createState() => _GlassmorphicQuipsSheetState();
}
class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
with SingleTickerProviderStateMixin {
late AnimationController _glassController;
late Animation<double> _glassAnimation;
late Animation<double> _blurAnimation;
List<Post> _quips = [];
ThreadNode? _threadTree;
bool _isLoading = true;
String? _error;
final TextEditingController _quipController = TextEditingController();
bool _isPostingQuip = false;
// Expanded quip state
String? _expandedQuipId;
ThreadNode? _expandedThreadNode;
@override
void initState() {
super.initState();
_glassController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_glassAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _glassController,
curve: Curves.easeOutCubic,
));
_blurAnimation = Tween<double>(
begin: 0.0,
end: 10.0,
).animate(CurvedAnimation(
parent: _glassController,
curve: Curves.easeOutCubic,
));
_loadQuips();
}
@override
void dispose() {
_glassController.dispose();
_quipController.dispose();
super.dispose();
}
Future<void> _loadQuips() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = ref.read(apiServiceProvider);
final quips = await api.getPostChain(widget.postId);
if (mounted) {
setState(() {
_quips = quips;
if (quips.isNotEmpty) {
_threadTree = ThreadNode.buildTree(quips);
}
_isLoading = false;
});
_glassController.forward();
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Future<void> _postQuip() async {
if (_quipController.text.trim().isEmpty) return;
setState(() => _isPostingQuip = true);
try {
final api = ref.read(apiServiceProvider);
await api.publishComment(
postId: widget.postId,
body: _quipController.text.trim(),
);
_quipController.clear();
// Refresh quips
await _loadQuips();
widget.onQuipPosted?.call();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Quip posted!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to post quip: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isPostingQuip = false);
}
}
}
void _expandQuip(ThreadNode node) {
setState(() {
_expandedQuipId = node.post.id;
_expandedThreadNode = node;
});
}
void _collapseQuip() {
setState(() {
_expandedQuipId = null;
_expandedThreadNode = null;
});
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.95,
snap: true,
snapSizes: const [0.6, 0.8, 0.95],
builder: (context, scrollController) {
return AnimatedBuilder(
animation: _glassController,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
color: AppTheme.scaffoldBg.withValues(alpha: 0.9 + (0.1 * _glassAnimation.value)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3 * _glassAnimation.value),
blurRadius: 30,
offset: const Offset(0, -10),
),
],
border: Border.all(
color: Colors.white.withValues(alpha: 0.1 * _glassAnimation.value),
width: 1,
),
),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _blurAnimation.value,
sigmaY: _blurAnimation.value,
),
child: Column(
children: [
// Glassmorphic drag handle
_buildGlassDragHandle(),
// HUD-style header
_buildHUDHeader(),
// Main content area
Expanded(
child: _buildMainContent(scrollController),
),
// Glassmorphic quip input
_buildGlassQuipInput(),
],
),
),
),
);
},
);
},
);
}
Widget _buildGlassDragHandle() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 16),
child: Container(
width: 48,
height: 5,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
boxShadow: [
BoxShadow(
color: Colors.white.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
);
}
Widget _buildHUDHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
),
child: Row(
children: [
// HUD-style title
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.brightNavy.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 16,
color: AppTheme.brightNavy,
),
const SizedBox(width: 6),
Text(
'QUIPS',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
],
),
),
const Spacer(),
// Animated count
AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Text(
'${(_threadTree?.totalCount ?? widget.initialQuipCount)}',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
// Refresh button
GestureDetector(
onTap: _loadQuips,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.refresh,
size: 18,
color: Colors.white.withValues(alpha: 0.8),
),
),
),
],
),
);
}
Widget _buildMainContent(ScrollController scrollController) {
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.brightNavy),
),
),
const SizedBox(height: 16),
Text(
'Loading quips...',
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
),
),
],
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red.withValues(alpha: 0.8),
),
const SizedBox(height: 16),
Text(
'Failed to load quips',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
_error!,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadQuips,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withValues(alpha: 0.8),
foregroundColor: Colors.white,
),
child: Text('Try Again'),
),
],
),
),
),
);
}
if (_threadTree == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.chat_bubble_outline,
size: 48,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 16),
Text(
'No quips yet',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
'Be the first to drop a quip!',
style: GoogleFonts.inter(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
}
// If we have an expanded quip, show the kinetic thread widget
if (_expandedThreadNode != null) {
return Column(
children: [
// Back button
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
GestureDetector(
onTap: _collapseQuip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_back,
size: 18,
color: Colors.white,
),
const SizedBox(width: 6),
Text(
'Back to Quips',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
),
// Kinetic thread view
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: KineticThreadWidget(
rootNode: _expandedThreadNode!,
onReplyPosted: _loadQuips,
),
),
),
),
],
);
}
// Show glassmorphic quip cards
return FadeTransition(
opacity: _glassAnimation,
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _threadTree!.children.length,
itemBuilder: (context, index) {
final child = _threadTree!.children[index];
return _buildGlassQuipCard(child, index);
},
),
);
}
Widget _buildGlassQuipCard(ThreadNode node, int index) {
final isExpanded = _expandedQuipId == node.post.id;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
transform: Matrix4.translationValues(0, 0, 0),
child: GestureDetector(
onTap: () => _expandQuip(node),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.white.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author row
Row(
children: [
// Glassmorphic avatar
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.brightNavy.withValues(alpha: 0.3),
AppTheme.egyptianBlue.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Center(
child: Text(
'L${node.depth}',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 12),
// Author info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.post.author?.displayName ?? 'Anonymous',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
Text(
timeago.format(node.post.createdAt),
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 12,
),
),
],
),
),
// Reply count indicator
if (node.hasChildren)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.egyptianBlue.withValues(alpha: 0.4),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.subdirectory_arrow_right,
size: 12,
color: AppTheme.egyptianBlue,
),
const SizedBox(width: 4),
Text(
'${node.totalDescendants}',
style: GoogleFonts.inter(
color: AppTheme.egyptianBlue,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Quip content
Text(
node.post.body,
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 15,
height: 1.4,
),
maxLines: isExpanded ? null : 3,
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
// Media if present
if (node.post.imageUrl != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SignedMediaImage(
url: node.post.imageUrl!,
width: double.infinity,
height: 150,
fit: BoxFit.cover,
),
),
],
if (isExpanded && node.hasChildren) ...[
const SizedBox(height: 16),
// Nested replies preview
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thread Replies',
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...node.children.take(3).map((child) =>
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
'L${child.depth}',
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
child.post.body,
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
).toList(),
if (node.children.length > 3)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'+${node.children.length - 3} more replies',
style: GoogleFonts.inter(
color: AppTheme.egyptianBlue,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
const SizedBox(height: 8),
// Action buttons
Row(
children: [
GestureDetector(
onTap: () => _expandQuip(node),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: Colors.white.withValues(alpha: 0.8),
),
const SizedBox(width: 4),
Text(
isExpanded ? 'Collapse' : 'Expand Thread',
style: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
],
),
),
),
);
}
Widget _buildGlassQuipInput() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
border: Border(
top: BorderSide(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: TextField(
controller: _quipController,
maxLines: 2,
minLines: 1,
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 14,
),
decoration: InputDecoration(
hintText: 'Drop a quip...',
hintStyle: GoogleFonts.inter(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
),
const SizedBox(width: 12),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FloatingActionButton(
onPressed: (_quipController.text.trim().isNotEmpty && !_isPostingQuip)
? _postQuip
: null,
backgroundColor: _quipController.text.isNotEmpty
? AppTheme.brightNavy.withValues(alpha: 0.9)
: Colors.white.withValues(alpha: 0.2),
mini: true,
child: _isPostingQuip
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Icon(
Icons.send,
size: 16,
color: Colors.white,
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,805 @@
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 'package:flutter_animate/flutter_animate.dart';
import '../models/post.dart';
import '../models/thread_node.dart';
import '../providers/api_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/media/signed_media_image.dart';
import '../widgets/post/post_actions.dart';
/// Kinetic Spatial Engine widget for layer-based thread navigation
class KineticThreadWidget extends ConsumerStatefulWidget {
final ThreadNode rootNode;
final Function(ThreadNode)? onLayerChanged;
final Function()? onReplyPosted;
const KineticThreadWidget({
super.key,
required this.rootNode,
this.onLayerChanged,
this.onReplyPosted,
});
@override
ConsumerState<KineticThreadWidget> createState() => _KineticThreadWidgetState();
}
class _KineticThreadWidgetState extends ConsumerState<KineticThreadWidget>
with TickerProviderStateMixin {
// Layer navigation state
ThreadNode? _currentFocusNode;
List<ThreadNode> _layerStack = [];
final PageController _pageController = PageController();
// Animation controllers
late AnimationController _layerTransitionController;
late AnimationController _lineageBarController;
late AnimationController _impactController;
// Animations
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
// Reply state
bool _showInlineReply = false;
final TextEditingController _replyController = TextEditingController();
bool _isPostingReply = false;
// Rail collapsing state
final Set<String> _collapsedRails = <String>{};
@override
void initState() {
super.initState();
_currentFocusNode = widget.rootNode;
_layerStack = [widget.rootNode];
_initializeAnimations();
}
void _initializeAnimations() {
// Layer transition animations
_layerTransitionController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _layerTransitionController,
curve: Curves.easeOutCubic,
));
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _layerTransitionController,
curve: Curves.easeOutBack,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _layerTransitionController,
curve: Curves.easeInOut,
));
// Lineage bar animation
_lineageBarController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Impact animation for new replies
_impactController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
}
@override
void dispose() {
_layerTransitionController.dispose();
_lineageBarController.dispose();
_impactController.dispose();
_pageController.dispose();
_replyController.dispose();
super.dispose();
}
void _drillDownToLayer(ThreadNode targetNode) {
setState(() {
_currentFocusNode = targetNode;
_layerStack.add(targetNode);
_showInlineReply = false;
});
_layerTransitionController.forward().then((_) {
_layerTransitionController.reset();
});
_lineageBarController.forward();
widget.onLayerChanged?.call(targetNode);
}
void _scrubToLayer(int layerIndex) {
if (layerIndex < 0 || layerIndex >= _layerStack.length) return;
setState(() {
_currentFocusNode = _layerStack[layerIndex];
_layerStack = _layerStack.sublist(0, layerIndex + 1);
_showInlineReply = false;
});
_layerTransitionController.forward().then((_) {
_layerTransitionController.reset();
});
widget.onLayerChanged?.call(_currentFocusNode!);
}
void _toggleRailCollapse(ThreadNode node) {
setState(() {
if (_collapsedRails.contains(node.post.id)) {
_collapsedRails.remove(node.post.id);
} else {
_collapsedRails.add(node.post.id);
// Shatter animation
_impactController.forward().then((_) {
_impactController.reset();
});
}
});
}
bool _isRailCollapsed(ThreadNode node) {
return _collapsedRails.contains(node.post.id);
}
Future<void> _submitInlineReply() async {
if (_replyController.text.trim().isEmpty || _currentFocusNode == null) return;
setState(() => _isPostingReply = true);
try {
final api = ref.read(apiServiceProvider);
await api.publishComment(
postId: _currentFocusNode!.post.id,
body: _replyController.text.trim(),
);
_replyController.clear();
setState(() {
_showInlineReply = false;
_isPostingReply = false;
});
// Impact animation
_impactController.forward().then((_) {
_impactController.reset();
});
widget.onReplyPosted?.call();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Reply posted!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to post reply: $e'),
backgroundColor: Colors.red,
),
);
}
setState(() => _isPostingReply = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Kinetic Trace Bar (Lineage Scrubber)
_buildKineticTraceBar(),
// Main content area with layer navigation
Expanded(
child: _buildLayerContent(),
),
// Inline reply composer
if (_showInlineReply && _currentFocusNode != null)
_buildInlineReplyComposer(),
],
);
}
Widget _buildKineticTraceBar() {
return AnimatedBuilder(
animation: _lineageBarController,
builder: (context, child) {
return Container(
height: 60,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
// Back button
if (_layerStack.length > 1)
GestureDetector(
onTap: () => _scrubToLayer(_layerStack.length - 2),
child: Container(
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.arrow_back,
size: 20,
color: AppTheme.brightNavy,
),
),
),
// Lineage avatars
Expanded(
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _layerStack.length,
itemBuilder: (context, index) {
final node = _layerStack[index];
final isCurrentLayer = index == _layerStack.length - 1;
return GestureDetector(
onTap: () => _scrubToLayer(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isCurrentLayer
? AppTheme.brightNavy
: AppTheme.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: isCurrentLayer
? null
: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Mini avatar
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isCurrentLayer
? Colors.white
: AppTheme.brightNavy.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'L${node.depth}',
style: GoogleFonts.inter(
color: isCurrentLayer
? AppTheme.brightNavy
: AppTheme.brightNavy,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 6),
// Author name
Text(
node.post.author?.displayName ?? 'Anonymous',
style: GoogleFonts.inter(
color: isCurrentLayer
? Colors.white
: AppTheme.navyBlue,
fontSize: 12,
fontWeight: isCurrentLayer
? FontWeight.w600
: FontWeight.w500,
),
),
],
),
),
);
},
),
),
],
),
);
},
);
}
Widget _buildLayerContent() {
if (_currentFocusNode == null) {
return const Center(child: Text('No content'));
}
return PageView.builder(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(), // Disable manual swiping
itemCount: 1,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _layerTransitionController,
builder: (context, child) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: _buildFocusLayer(_currentFocusNode!),
),
),
);
},
);
},
);
}
Widget _buildFocusLayer(ThreadNode focusNode) {
return Column(
children: [
// Focus post (blooms to full width)
_buildFocusPost(focusNode),
// Children list (high-velocity scroll)
if (focusNode.hasChildren)
Expanded(
child: _buildChildrenList(focusNode.children),
),
],
);
}
Widget _buildFocusPost(ThreadNode node) {
return Hero(
tag: 'post_${node.post.id}',
child: Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.2),
width: 2,
),
boxShadow: [
BoxShadow(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author and metadata
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'L${node.depth}',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.post.author?.displayName ?? 'Anonymous',
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Text(
timeago.format(node.post.createdAt),
style: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
],
),
),
// Post content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
node.post.body,
style: GoogleFonts.inter(
fontSize: 16,
color: AppTheme.navyText,
height: 1.5,
),
),
),
// Media if present
if (node.post.imageUrl != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SignedMediaImage(
url: node.post.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
),
),
],
// Actions
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => setState(() => _showInlineReply = !_showInlineReply),
icon: Icon(Icons.reply, size: 18),
label: Text('Reply'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 12),
if (node.hasChildren)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 16,
color: AppTheme.egyptianBlue,
),
const SizedBox(width: 4),
Text(
'${node.totalDescendants}',
style: GoogleFonts.inter(
color: AppTheme.egyptianBlue,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildChildrenList(List<ThreadNode> children) {
// Filter out collapsed rails
final visibleChildren = children.where((child) => !_isRailCollapsed(child)).toList();
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: visibleChildren.length,
itemBuilder: (context, index) {
final child = visibleChildren[index];
return _buildChildItem(child, index);
},
);
}
Widget _buildChildItem(ThreadNode child, int index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
transform: Matrix4.translationValues(0, 0, 0),
child: GestureDetector(
onTap: () => _drillDownToLayer(child),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Power Rail (collapsible thread indicator)
GestureDetector(
onLongPress: () => _toggleRailCollapse(child),
onDoubleTap: () => _toggleRailCollapse(child),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 4,
height: 40,
decoration: BoxDecoration(
color: _isRailCollapsed(child)
? Colors.red.withValues(alpha: 0.5)
: AppTheme.brightNavy.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(width: 12),
// Child content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'L${child.depth}',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
child.post.author?.displayName ?? 'Anonymous',
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
if (child.hasChildren) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${child.totalDescendants}',
style: GoogleFonts.inter(
color: AppTheme.egyptianBlue,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
child.post.body,
style: GoogleFonts.inter(
color: AppTheme.navyText,
fontSize: 14,
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Drill down indicator
Icon(
Icons.arrow_forward_ios,
size: 16,
color: AppTheme.textSecondary,
),
],
),
),
),
);
}
Widget _buildInlineReplyComposer() {
return AnimatedBuilder(
animation: _impactController,
builder: (context, child) {
return Transform.scale(
scale: 1.0 + (_impactController.value * 0.05),
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.3),
width: 2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.reply, size: 20, color: AppTheme.navyBlue),
const SizedBox(width: 8),
Text(
'Replying to ${_currentFocusNode!.post.author?.displayName ?? 'Anonymous'}',
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.navyBlue,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _showInlineReply = false),
child: Icon(Icons.close, size: 20, color: AppTheme.navyBlue),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _replyController,
maxLines: 3,
minLines: 1,
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.navyText,
),
decoration: InputDecoration(
hintText: 'Write your reply...',
hintStyle: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.textSecondary,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppTheme.cardSurface,
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 12),
Row(
children: [
Text(
'${_replyController.text.length}/500',
style: GoogleFonts.inter(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const Spacer(),
ElevatedButton(
onPressed: (_replyController.text.trim().isNotEmpty && !_isPostingReply)
? _submitInlineReply
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: _isPostingReply
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
'Reply',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
},
);
}
}

View file

@ -5,9 +5,9 @@ import '../models/post.dart';
import '../models/thread_node.dart'; import '../models/thread_node.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/threaded_comment_widget.dart'; import '../widgets/glassmorphic_quips_sheet.dart';
/// Draggable bottom sheet for video comments (TikTok-style) /// Glassmorphic video comments sheet with kinetic navigation
class VideoCommentsSheet extends StatefulWidget { class VideoCommentsSheet extends StatefulWidget {
final String postId; final String postId;
final int initialCommentCount; final int initialCommentCount;
@ -24,404 +24,26 @@ class VideoCommentsSheet extends StatefulWidget {
State<VideoCommentsSheet> createState() => _VideoCommentsSheetState(); State<VideoCommentsSheet> createState() => _VideoCommentsSheetState();
} }
class _VideoCommentsSheetState extends State<VideoCommentsSheet> class _VideoCommentsSheetState extends State<VideoCommentsSheet> {
with SingleTickerProviderStateMixin { // This is now just a wrapper around GlassmorphicQuipsSheet
late AnimationController _animationController; // All functionality is delegated to the glassmorphic sheet
late Animation<double> _animation;
List<Post> _comments = [];
ThreadNode? _threadTree;
bool _isLoading = true;
String? _error;
final TextEditingController _commentController = TextEditingController();
bool _isPostingComment = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
_loadComments();
} }
@override @override
void dispose() { void dispose() {
_animationController.dispose();
_commentController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _loadComments() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final comments = await ApiService.instance.getPostChain(widget.postId);
if (mounted) {
setState(() {
_comments = comments;
if (comments.isNotEmpty) {
_threadTree = ThreadNode.buildTree(comments);
}
_isLoading = false;
});
_animationController.forward();
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DraggableScrollableSheet( return GlassmorphicQuipsSheet(
initialChildSize: 0.5, // Start at 50% screen height postId: widget.postId,
minChildSize: 0.3, // Minimum 30% initialQuipCount: widget.initialCommentCount,
maxChildSize: 0.95, // Maximum 95% onQuipPosted: widget.onCommentPosted,
snap: true,
snapSizes: const [0.5, 0.8, 0.95],
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: AppTheme.scaffoldBg,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Column(
children: [
// Drag handle
_buildDragHandle(),
// Header with comment count
_buildHeader(),
// Comments list or loading/error state
Expanded(
child: _buildCommentsList(scrollController),
),
// Comment input
_buildCommentInput(),
],
),
);
},
);
}
Widget _buildDragHandle() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textDisabled,
borderRadius: BorderRadius.circular(2),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
'Comments',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 18,
),
),
const SizedBox(width: 8),
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1 * _animation.value),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${(_threadTree?.totalCount ?? widget.initialCommentCount)}',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
},
),
const Spacer(),
IconButton(
onPressed: _loadComments,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
tooltip: 'Refresh comments',
),
],
),
);
}
Widget _buildCommentsList(ScrollController scrollController) {
if (_isLoading) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.brightNavy),
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red[400],
),
const SizedBox(height: 16),
Text(
'Failed to load comments',
style: GoogleFonts.literata(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
),
),
const SizedBox(height: 8),
Text(
_error!,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadComments,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
),
child: Text('Try Again'),
),
],
),
),
);
}
if (_threadTree == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 48,
color: AppTheme.textDisabled,
),
const SizedBox(height: 16),
Text(
'No comments yet',
style: GoogleFonts.literata(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
),
),
const SizedBox(height: 8),
Text(
'Be the first to comment!',
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
);
}
return FadeTransition(
opacity: _animation,
child: RefreshIndicator(
onRefresh: _loadComments,
color: AppTheme.brightNavy,
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: 1, // We render the entire tree in one widget
itemBuilder: (context, index) {
return ThreadedCommentWidget(
node: _threadTree!,
onReply: _handleReply,
onLike: _handleLike,
isRootPost: true,
);
},
),
),
);
}
Widget _buildCommentInput() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
border: Border(
top: BorderSide(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
),
),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
maxLines: 1,
decoration: InputDecoration(
hintText: 'Add a comment...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppTheme.navyBlue.withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
),
const SizedBox(width: 8),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FloatingActionButton(
onPressed: _isPostingComment ? null : _postComment,
backgroundColor: _commentController.text.isNotEmpty
? AppTheme.brightNavy
: AppTheme.textDisabled,
mini: true,
child: _isPostingComment
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Icon(
Icons.send,
size: 16,
color: Colors.white,
),
),
),
],
),
),
);
}
Future<void> _postComment() async {
if (_commentController.text.trim().isEmpty) return;
setState(() => _isPostingComment = true);
try {
// Post comment using Go API
await ApiService.instance.publishComment(
postId: widget.postId,
body: _commentController.text.trim(),
);
_commentController.clear();
// Refresh comments
await _loadComments();
// Notify parent if callback provided
widget.onCommentPosted?.call();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Comment posted!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to post comment: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isPostingComment = false);
}
}
}
void _handleReply() {
// TODO: Implement reply functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reply functionality coming soon!'),
duration: Duration(seconds: 2),
),
);
}
void _handleLike() {
// TODO: Implement like functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Like functionality coming soon!'),
duration: Duration(seconds: 2),
),
); );
} }
} }

View file

@ -430,6 +430,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_animate:
dependency: "direct main"
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_colorpicker: flutter_colorpicker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -715,6 +723,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View file

@ -33,6 +33,7 @@ dependencies:
video_player: ^2.10.1 video_player: ^2.10.1
visibility_detector: ^0.4.0+2 visibility_detector: ^0.4.0+2
ffmpeg_kit_flutter_new: ^4.1.0 ffmpeg_kit_flutter_new: ^4.1.0
flutter_animate: ^4.5.0
# Rich Text Editor # Rich Text Editor
flutter_quill: ^11.0.0 flutter_quill: ^11.0.0