Chain updates
This commit is contained in:
parent
b9351b76ae
commit
ca59f3286a
|
|
@ -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: [
|
|
||||||
// Root post
|
|
||||||
ThreadedCommentWidget(
|
|
||||||
node: _threadTree!,
|
|
||||||
onReply: _handleReply,
|
|
||||||
onLike: _handleLike,
|
|
||||||
onNavigateToParent: _navigateToParent,
|
|
||||||
onNavigateToChild: _navigateToChild,
|
|
||||||
isRootPost: true,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Thread statistics
|
|
||||||
_buildThreadStats(),
|
|
||||||
|
|
||||||
// Reply input area
|
|
||||||
_buildReplyInput(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildThreadStats() {
|
|
||||||
if (_threadTree == null || !_threadTree!.hasChildren) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
style: GoogleFonts.inter(
|
||||||
color: AppTheme.textSecondary,
|
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,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
) : KineticThreadWidget(
|
||||||
TextField(
|
rootNode: _threadTree!,
|
||||||
maxLines: 3,
|
onLayerChanged: _handleLayerChange,
|
||||||
decoration: InputDecoration(
|
onReplyPosted: _loadThread,
|
||||||
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() {
|
void _handleLayerChange(ThreadNode newFocusNode) {
|
||||||
if (_threadTree == null) return 0;
|
// Update app bar title to reflect current layer
|
||||||
|
setState(() {});
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
907
sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart
Normal file
907
sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
805
sojorn_app/lib/widgets/kinetic_thread_widget.dart
Normal file
805
sojorn_app/lib/widgets/kinetic_thread_widget.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
minChildSize: 0.3, // Minimum 30%
|
|
||||||
maxChildSize: 0.95, // Maximum 95%
|
|
||||||
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,
|
postId: widget.postId,
|
||||||
body: _commentController.text.trim(),
|
initialQuipCount: widget.initialCommentCount,
|
||||||
);
|
onQuipPosted: widget.onCommentPosted,
|
||||||
|
|
||||||
_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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue