import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:video_player/video_player.dart'; import 'package:path_provider/path_provider.dart'; class TweetTemplateWidget extends StatefulWidget { final Uint8List? avatarBytes; final String displayName; final bool isVerified; final String username; final String text; final Uint8List? imageBytes; final Uint8List? videoBytes; final DateTime timestamp; final Map? engagement; // {replies, retweets, likes, views} const TweetTemplateWidget({ Key? key, this.avatarBytes, required this.displayName, this.isVerified = false, required this.username, required this.text, this.imageBytes, this.videoBytes, required this.timestamp, this.engagement, }) : assert(imageBytes == null || videoBytes == null, 'Cannot have both imageBytes and videoBytes set at the same time'), super(key: key); @override State createState() => _TweetTemplateWidgetState(); } class _TweetTemplateWidgetState extends State { VideoPlayerController? _videoController; late String _normalizedUsername; File? _tempVideoFile; @override void initState() { super.initState(); _normalizedUsername = _normalizeUsername(widget.username); _initializeMedia(); } String _normalizeUsername(String username) { final trimmed = username.trim(); // if empty or just "@", return default if (trimmed.isEmpty || trimmed == '@') return '@anonymous'; // add @ if it doesnt start with it return trimmed.startsWith('@') ? trimmed : '@$trimmed'; } Future _initializeMedia() async { if (widget.videoBytes == null) return; // write video bytes to a temp file final tempDir = await getTemporaryDirectory(); final tempPath = '${tempDir.path}/temp_video_${DateTime.now().millisecondsSinceEpoch}.mp4'; _tempVideoFile = File(tempPath); await _tempVideoFile!.writeAsBytes(widget.videoBytes!); _videoController = VideoPlayerController.file(_tempVideoFile!) ..initialize().then((_) { if (mounted) { setState(() {}); _videoController?.play(); _videoController?.setLooping(true); } }); } @override void dispose() { _videoController?.dispose(); _tempVideoFile?.delete().catchError((_) {}); super.dispose(); } @override Widget build(BuildContext context) { return Container( color: Colors.black, child: Center( child: LayoutBuilder( builder: (context, constraints) { final size = constraints.biggest.shortestSide - 20; return Container( width: size, height: size, color: Colors.black, child: Center( child: FittedBox( fit: BoxFit.contain, child: SizedBox( width: 450, child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), color: Colors.black, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // header with avatar and user info Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Avatar Container( width: 40, height: 40, margin: const EdgeInsets.only(right: 12), decoration: BoxDecoration( color: const Color.fromRGBO(51, 54, 57, 1), borderRadius: BorderRadius.circular(20), ), child: widget.avatarBytes != null ? ClipRRect( borderRadius: BorderRadius.circular(20), child: Image.memory( widget.avatarBytes!, fit: BoxFit.cover, ), ) : null, ), // user info section Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( widget.displayName, style: const TextStyle( fontFamily: '-apple-system', fontSize: 15, fontWeight: FontWeight.w700, color: Color.fromRGBO(231, 233, 234, 1), height: 1.33, ), ), if (widget.isVerified) ...[ const SizedBox(width: 4), SvgPicture.string( _verifiedBadgeSvg, width: 18, height: 18, ), ], ], ), Text( _normalizedUsername, style: const TextStyle( fontFamily: '-apple-system', fontSize: 15, color: Color.fromRGBO(113, 118, 123, 1), height: 1.33, ), ), ], ), ], ), const SizedBox(height: 12), // tweet text Text( widget.text, style: const TextStyle( fontFamily: '-apple-system', fontSize: 15, color: Color.fromRGBO(231, 233, 234, 1), height: 1.33, ), ), const SizedBox(height: 12), // media content (image or vdeo) if (widget.imageBytes != null) ...[ Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: Border.all( color: const Color.fromRGBO(47, 51, 54, 1), width: 1, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.memory( widget.imageBytes!, fit: BoxFit.cover, ), ), ), const SizedBox(height: 12), ], if (widget.videoBytes != null) ...[ Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: Border.all( color: const Color.fromRGBO(47, 51, 54, 1), width: 1, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: (_videoController?.value.isInitialized ?? false) ? AspectRatio( aspectRatio: _videoController!.value.aspectRatio, child: VideoPlayer(_videoController!), ) : Container( height: 200, color: const Color.fromRGBO(51, 54, 57, 1), child: const Center( child: CircularProgressIndicator( color: Color.fromRGBO(29, 155, 240, 1), ), ), ), ), ), const SizedBox(height: 12), ], // timestmp and source RichText( text: TextSpan( style: const TextStyle( fontFamily: '-apple-system', fontSize: 15, color: Color.fromRGBO(113, 118, 123, 1), ), children: [ TextSpan(text: _formatTimestamp(widget.timestamp)), const TextSpan(text: ' · '), const TextSpan( text: 'via tweetforge.imbenji.net', style: TextStyle( color: Color.fromRGBO(29, 155, 240, 1), ), ), ], ), ), // engagement metrics if provided if (widget.engagement != null) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.only(top: 12), decoration: const BoxDecoration( border: Border( top: BorderSide( color: Color.fromRGBO(47, 51, 54, 1), width: 1, ), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (widget.engagement!['replies'] != null) _buildEngagementItem( _replyIcon, widget.engagement!['replies']!, ), if (widget.engagement!['retweets'] != null) _buildEngagementItem( _retweetIcon, widget.engagement!['retweets']!, ), if (widget.engagement!['likes'] != null) _buildEngagementItem( _likeIcon, widget.engagement!['likes']!, ), if (widget.engagement!['views'] != null) _buildEngagementItem( _viewsIcon, widget.engagement!['views']!, ), ], ), ), ], ], ), ), ), ), ), ); }, ), ), ); } Widget _buildEngagementItem(String iconSvg, int count) { return Row( children: [ SvgPicture.string( iconSvg, width: 18.75, height: 18.75, colorFilter: const ColorFilter.mode( Color.fromRGBO(113, 118, 123, 1), BlendMode.srcIn, ), ), const SizedBox(width: 4), Text( _formatCount(count), style: const TextStyle( fontFamily: '-apple-system', fontSize: 13, color: Color.fromRGBO(113, 118, 123, 1), ), ), ], ); } String _formatCount(int count) { if (count >= 1000000000) { final formatted = (count / 1000000000).toStringAsFixed(1); return formatted.replaceAll(RegExp(r'\.0$'), '') + 'B'; } if (count >= 1000000) { final formatted = (count / 1000000).toStringAsFixed(1); return formatted.replaceAll(RegExp(r'\.0$'), '') + 'M'; } if (count >= 1000) { final formatted = (count / 1000).toStringAsFixed(1); return formatted.replaceAll(RegExp(r'\.0$'), '') + 'K'; } return count.toString(); } String _formatTimestamp(DateTime date) { final hours = date.hour; final minutes = date.minute.toString().padLeft(2, '0'); final ampm = hours >= 12 ? 'PM' : 'AM'; final hour12 = hours % 12 == 0 ? 12 : hours % 12; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; final month = months[date.month - 1]; final day = date.day; final year = date.year; return '$hour12:$minutes $ampm · $month $day, $year'; } // verified badge svg path static const String _verifiedBadgeSvg = ''' '''; // engagment icons (simplified versions) static const String _replyIcon = ''' '''; static const String _retweetIcon = ''' '''; static const String _likeIcon = ''' '''; static const String _viewsIcon = ''' '''; }