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 '../../providers/api_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/threaded_comment_widget.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../../widgets/kinetic_thread_widget.dart';
|
||||
|
||||
/// Screen for displaying threaded conversations (Reddit-style)
|
||||
/// Screen for displaying threaded conversations with Kinetic Spatial Engine
|
||||
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
||||
final String rootPostId;
|
||||
final Post? rootPost;
|
||||
|
|
@ -161,164 +160,30 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
return RefreshIndicator(
|
||||
onRefresh: _loadThread,
|
||||
color: AppTheme.brightNavy,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
// Root post
|
||||
ThreadedCommentWidget(
|
||||
node: _threadTree!,
|
||||
onReply: _handleReply,
|
||||
onLike: _handleLike,
|
||||
onNavigateToParent: _navigateToParent,
|
||||
onNavigateToChild: _navigateToChild,
|
||||
isRootPost: true,
|
||||
),
|
||||
|
||||
// Thread statistics
|
||||
_buildThreadStats(),
|
||||
|
||||
// Reply input area
|
||||
_buildReplyInput(),
|
||||
],
|
||||
child: _threadTree == null ? Center(
|
||||
child: Text(
|
||||
'Thread not found',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
) : KineticThreadWidget(
|
||||
rootNode: _threadTree!,
|
||||
onLayerChanged: _handleLayerChange,
|
||||
onReplyPosted: _loadThread,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
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 _handleLayerChange(ThreadNode newFocusNode) {
|
||||
// Update app bar title to reflect current layer
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _handleReply() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ComposeScreen(
|
||||
chainParentPost: widget.rootPost,
|
||||
),
|
||||
),
|
||||
);
|
||||
// This is now handled inline by the KineticThreadWidget
|
||||
// Keeping for compatibility
|
||||
}
|
||||
|
||||
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 '../services/api_service.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 {
|
||||
final String postId;
|
||||
final int initialCommentCount;
|
||||
|
|
@ -24,404 +24,26 @@ class VideoCommentsSheet extends StatefulWidget {
|
|||
State<VideoCommentsSheet> createState() => _VideoCommentsSheetState();
|
||||
}
|
||||
|
||||
class _VideoCommentsSheetState extends State<VideoCommentsSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
List<Post> _comments = [];
|
||||
ThreadNode? _threadTree;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
bool _isPostingComment = false;
|
||||
class _VideoCommentsSheetState extends State<VideoCommentsSheet> {
|
||||
// This is now just a wrapper around GlassmorphicQuipsSheet
|
||||
// All functionality is delegated to the glassmorphic sheet
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_loadComments();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_commentController.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
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
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,
|
||||
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),
|
||||
),
|
||||
return GlassmorphicQuipsSheet(
|
||||
postId: widget.postId,
|
||||
initialQuipCount: widget.initialCommentCount,
|
||||
onQuipPosted: widget.onCommentPosted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,6 +430,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -715,6 +723,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ dependencies:
|
|||
video_player: ^2.10.1
|
||||
visibility_detector: ^0.4.0+2
|
||||
ffmpeg_kit_flutter_new: ^4.1.0
|
||||
flutter_animate: ^4.5.0
|
||||
|
||||
# Rich Text Editor
|
||||
flutter_quill: ^11.0.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue