Files
Quote-Generator/lib/tweet_template_widget.dart
ImBenji 537fc4f750 Add v2 quote API with video support and Flutter tweet template widget
- Implement Express routes for creating, updating, retrieving, and deleting quote sessions
- Add video detection and rendering pipeline using Playwright and FFmpeg
- Add caching utilities for images and videos
- Provide helpers for formatting counts, timestamps, and normalizing usernames
- Add snapshot creation and retrieval endpoints
- Implement SSE endpoint for render progress updates
- Add Flutter widget for rendering tweet templates with image/video support
2026-02-13 21:56:26 +00:00

403 lines
17 KiB
Dart

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<String, int>? 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<TweetTemplateWidget> createState() => _TweetTemplateWidgetState();
}
class _TweetTemplateWidgetState extends State<TweetTemplateWidget> {
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<void> _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 = '''
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<path fill="#1D9BF0" d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"/>
</svg>
''';
// engagment icons (simplified versions)
static const String _replyIcon = '''
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"/>
</svg>
''';
static const String _retweetIcon = '''
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/>
</svg>
''';
static const String _likeIcon = '''
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/>
</svg>
''';
static const String _viewsIcon = '''
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"/>
</svg>
''';
}