import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:image/image.dart' as img; import 'package:provider/provider.dart'; import 'package:quotegen_client/providers/backend_status_provider.dart'; import 'package:quotegen_client/providers/form_provider.dart'; import 'package:quotegen_client/providers/gallery_provider.dart'; import 'package:quotegen_client/providers/render_provider.dart'; import 'package:quotegen_client/providers/session_provider.dart'; import 'package:quotegen_client/widgets/ad_banner.dart'; import 'package:quotegen_client/widgets/tweet_template_widget.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:intl/intl.dart'; import 'package:file_saver/file_saver.dart'; import 'package:video_player/video_player.dart'; // number formatter for engagement fields class NumberTextInputFormatter extends TextInputFormatter { final NumberFormat _formatter = NumberFormat('#,###'); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { if (newValue.text.isEmpty) { return newValue; } // remove all non-digits String digitsOnly = newValue.text.replaceAll(RegExp(r'[^0-9]'), ''); if (digitsOnly.isEmpty) { return TextEditingValue.empty; } // format with commas int number = int.parse(digitsOnly); String formatted = _formatter.format(number); return TextEditingValue( text: formatted, selection: TextSelection.collapsed(offset: formatted.length), ); } } class HomePage extends StatefulWidget { static GoRoute route = GoRoute( path: "/", builder: (context, state) => HomePage(), ); @override State createState() => _HomePageState(); } class _HomePageState extends State { late TextEditingController _repliesController; late TextEditingController _retweetsController; late TextEditingController _likesController; late TextEditingController _viewsController; // key on the live tweet card so we can rasterize it for image export final GlobalKey _previewCaptureKey = GlobalKey(); @override void initState() { super.initState(); _repliesController = TextEditingController(); _retweetsController = TextEditingController(); _likesController = TextEditingController(); _viewsController = TextEditingController(); // do this after the first frame so providers are available WidgetsBinding.instance.addPostFrameCallback((_) async { final form = context.read(); final session = context.read(); await form.loadFormData(); // sync controller text after loading _repliesController.text = form.replies; _retweetsController.text = form.retweets; _likesController.text = form.likes; _viewsController.text = form.views; await session.initializeSession(form.buildFullRequest()); session.setLoaded(); }); } @override void dispose() { _repliesController.dispose(); _retweetsController.dispose(); _likesController.dispose(); _viewsController.dispose(); super.dispose(); } bool get isWideLayout { return MediaQuery.of(context).size.width >= 800; } @override Widget build(BuildContext context) { final session = context.watch(); if (session.isLoading) { return Scaffold( child: Center( child: CircularProgressIndicator(), ), ); } double topPadding = MediaQuery.of(context).padding.top; double bottomPadding = MediaQuery.of(context).padding.bottom; bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); if (isWideLayout) { return Scaffold( child: Center( child: IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 600, height: 600, child: _buildPreview(context) ), Gap(14), ConstrainedBox( constraints: BoxConstraints( maxWidth: 600, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Expanded(child: _buildForm(context)), Gap(14), AdBanner(), ], ), ), ], ), ).withPadding( horizontal: 14 ), ), ); } Widget mainContent = SingleChildScrollView( child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 600), child: Column( children: [ Gap(max(topPadding + 14, 14)), _buildPreview(context), Gap(8), Divider().withPadding( horizontal: 8 ), Gap(8), _buildForm(context), Gap(14), if (isMobilePlatform) Gap(100), if (!isMobilePlatform) AdBanner(), Gap(max(bottomPadding, 14)), ], ), ), ), ); if (isMobilePlatform) { return Scaffold( child: Stack( children: [ mainContent, Positioned( left: 0, right: 0, bottom: max(bottomPadding, 0), child: AdBanner(), ), ], ), ); } return Scaffold( child: mainContent, ); } Widget _buildPreview(BuildContext context) { final form = context.watch(); final session = context.watch(); final render = context.watch(); bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; bool isVideoInForm = form.postImage != null && _isVideoBytes(form.postImage!); bool isRendering = render.renderStatus == "rendering" || render.renderStatus == "pending"; bool renderDone = render.renderStatus == "completed" && render.postPreviewImage != null && !isVideo; bool videoDone = render.renderStatus == "completed" && isVideo && render.videoController != null && render.videoController!.value.isInitialized; bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); Widget previewChild; if (renderDone) { previewChild = Image.memory( render.postPreviewImage!, fit: BoxFit.contain, ); } else if (videoDone) { previewChild = Center( child: AspectRatio( aspectRatio: render.videoController!.value.aspectRatio, child: VideoPlayer(render.videoController!), ), ); } else { previewChild = RepaintBoundary( key: _previewCaptureKey, child: TweetTemplateWidget( displayName: form.displayName, username: form.handle, isVerified: form.verified, text: form.content, timestamp: DateTime( form.date.year, form.date.month, form.date.day, form.time.hour, form.time.minute, ), avatarBytes: form.avatarImage, imageBytes: !isVideoInForm ? form.postImage : null, videoBytes: isVideoInForm ? form.postImage : null, engagement: form.replies == "" && form.retweets == "" && form.likes == "" && form.views == "" ? null : { "replies": int.tryParse(form.replies.replaceAll(",", "")) ?? 0, "retweets": int.tryParse(form.retweets.replaceAll(",", "")) ?? 0, "likes": int.tryParse(form.likes.replaceAll(",", "")) ?? 0, "views": int.tryParse(form.views.replaceAll(",", "")) ?? 0, }, ), ); } return Column( children: [ AspectRatio( aspectRatio: 1, child: OutlinedContainer( backgroundColor: Colors.black, child: previewChild, ), ).withPadding( horizontal: 14, ), Gap(4), Text( "Videos are WIP. They may be slightly buggy." ).muted.textSmall, Gap(4), if (isVideoInForm) ...[ _videoControlPanel(context), ] else if (renderDone) ...[ Row( children: [ IconButton.secondary( icon: Icon(LucideIcons.save), onPressed: () async { final saved = await context.read().saveCurrentForm(context.read()); if (!context.mounted) return; showToast( context: context, builder: (ctx, overlay) => SurfaceCard( child: Basic( leading: Icon(saved ? LucideIcons.saveAll : LucideIcons.ban), title: Text(saved ? "Saved to gallery" : "Already saved"), ), ), ); }, ), Gap(8), if (!kIsWeb) ...[ Expanded( child: Button.primary( child: Text("Share"), leading: Icon(LucideIcons.share2), alignment: Alignment.center, onPressed: shareMedia, ), ), Gap(8), ], if (!isMobilePlatform) ...[ Expanded( child: Button.primary( child: Text("Download"), leading: Icon(LucideIcons.download), alignment: Alignment.center, onPressed: downloadMedia, ), ), Gap(8), ], IconButton.secondary( icon: Icon(LucideIcons.refreshCw), onPressed: () { final s = context.read(); final r = context.read(); final f = context.read(); bool isVid = f.postImage != null && _isVideoBytes(f.postImage!); r.renderOutput(s.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); }, ), ], ).withPadding(horizontal: 14), ] else ...[ Row( children: [ IconButton.secondary( icon: Icon(LucideIcons.save), onPressed: () async { final saved = await context.read().saveCurrentForm(context.read()); if (!context.mounted) return; showToast( context: context, builder: (ctx, overlay) => SurfaceCard( child: Basic( leading: Icon(saved ? LucideIcons.saveAll : LucideIcons.ban), title: Text(saved ? "Saved to gallery" : "Already saved"), ), ), ); }, ), Gap(8), Expanded( child: Button.primary( child: Text( isRendering ? "Rendering..." : "Render Image" ), leading: Icon(LucideIcons.image), alignment: Alignment.center, enabled: !isRendering && session.canRender, onPressed: () { final s = context.read(); final r = context.read(); r.renderOutput(s.currentSessionId, false, previewKey: _previewCaptureKey); }, ), ), ], ).withPadding( horizontal: 14, ), Gap(8), SizedBox( height: 4, width: double.infinity, child: LinearProgressIndicator( value: isRendering ? null : 0, ), ).withPadding( horizontal: 14, ), ], ], ); } Widget _videoControlPanel(BuildContext context) { final session = context.watch(); final render = context.watch(); final form = context.watch(); final backend = context.watch(); bool isRendering = render.renderStatus == "rendering" || render.renderStatus == "pending"; bool videoDone = render.renderStatus == "completed" && render.postPreviewImage != null && (render.postPreviewContentType?.startsWith("video/") ?? false); bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); // video rendering goes through the server, so if its down theres no point showing the button bool serverDown = backend.hasChecked && !backend.isOnline; String stageLabel; if (render.renderStage == "encoding") { stageLabel = "Encoding... ${render.renderProgress}%"; } else if (isRendering) { stageLabel = "Rendering... ${render.renderProgress}%"; } else { stageLabel = "Render Video"; } return Column( children: [ if (videoDone) ...[ Row( children: [ if (!kIsWeb) ...[ Expanded( child: Button.primary( child: Text("Share"), leading: Icon(LucideIcons.share2), alignment: Alignment.center, onPressed: shareMedia, ), ), Gap(8), ], if (!isMobilePlatform) ...[ Expanded( child: Button.primary( child: Text("Download"), leading: Icon(LucideIcons.download), alignment: Alignment.center, onPressed: downloadMedia, ), ), Gap(8), ], if (!serverDown) IconButton.secondary( icon: Icon(LucideIcons.refreshCw), onPressed: () { bool isVid = form.postImage != null && _isVideoBytes(form.postImage!); render.renderOutput(session.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); }, ), ], ).withPadding(horizontal: 14), ] else if (serverDown) ...[ Row( children: [ Expanded( child: SurfaceCard( child: Basic( leading: Icon(LucideIcons.cloudOff), title: Text("Video rendering unavailable"), subtitle: Text("Cant reach the server right now, try again in a bit."), ), ), ), ], ).withPadding(horizontal: 14), ] else ...[ Row( children: [ Expanded( child: Button.primary( enabled: !isRendering && session.canRender, alignment: Alignment.center, onPressed: () { bool isVid = form.postImage != null && _isVideoBytes(form.postImage!); render.renderOutput(session.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); }, leading: Icon(LucideIcons.boxes), child: Text(stageLabel), ), ), ], ).withPadding(horizontal: 14), ], Gap(8), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: SizedBox( height: 4, child: LinearProgressIndicator( value: videoDone ? 1.0 : (isRendering ? render.renderProgress / 100 : 0), color: videoDone ? Colors.green : null, ), ), ), ], ).withPadding(horizontal: 14), ] ); } Widget _buildForm(BuildContext context) { final form = context.watch(); final session = context.watch(); final render = context.watch(); return Column( mainAxisSize: MainAxisSize.min, children: [ IntrinsicHeight( child: Row( children: [ Expanded( child: ButtonGroup.vertical( children: [ ButtonGroup.horizontal( children: [ Expanded( child: TextField( placeholder: Text( "Display Name" ), features: [ InputFeature.leading( Icon( LucideIcons.user ), ) ], initialValue: form.displayName, onChanged: (value) { form.setDisplayName(value); form.saveFormData(); session.onSubmit( changedField: "display_name", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ), VerticalDivider(), SizedBox( height: 10, child: IconButton.secondary( icon: Icon( LucideIcons.badgeCheck, color: form.verified ? Colors.blue.shade400 : Colors.gray.shade600, ), onPressed: () { form.setVerified(!form.verified); form.saveFormData(); session.onSubmit( changedField: "verified", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ) ], ), TextField( placeholder: Text( "Handle" ), features: [ InputFeature.leading( Icon( LucideIcons.atSign ) ) ], initialValue: form.handle, onChanged: (value) { form.setHandle(value); form.saveFormData(); session.onSubmit( changedField: "handle", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ) ], ) ), Gap(8), if (form.avatarImage == null) AspectRatio( aspectRatio: 1, child: Button.card( onPressed: () async { Uint8List? imageBytes = await pickImageFile(); if (imageBytes != null) { Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400); if (!mounted) return; form.setAvatarImage(resizedBytes ?? imageBytes); form.saveFormData(); session.onSubmit( changedField: "avatar_image", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); } }, child: Column( children: [ Row( children: [ Icon( LucideIcons.plus ), Gap(2), Icon( LucideIcons.user, ), ], ), Gap(4), Text( "Avatar", textAlign: TextAlign.center, ).xSmall.semiBold ], ), ), ) else AspectRatio( aspectRatio: 1, child: OutlinedContainer( child: ClipRRect( borderRadius: Theme.of(context).borderRadiusLg, child: GestureDetector( onTap: () { form.setAvatarImage(null); form.saveFormData(); session.onSubmit( changedField: "avatar_image", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, child: Stack( children: [ Positioned.fill( child: FittedBox( fit: BoxFit.cover, child: Image.memory( height: 1, width: 1, form.avatarImage!, fit: BoxFit.cover, ), ), ), Center( child: Icon( LucideIcons.trash2, color: Colors.white.withOpacity(0.9), shadows: [ Shadow( color: Colors.black.withOpacity(0.9), blurRadius: 6, ) ] ), ) ], ) ), ), ), ) ], ), ).withPadding( horizontal: 14 ), Gap(14), SizedBox( width: double.infinity, child: ButtonGroup.vertical( children: [ SizedBox( height: 100, child: TextField( placeholder: Text( "Content" ), initialValue: form.content, onChanged: (value) { form.setContent(value); form.saveFormData(); session.onSubmit( changedField: "content", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, expands: false, maxLines: 100, textAlignVertical: TextAlignVertical.top, keyboardType: TextInputType.multiline, ), ), ButtonGroup.horizontal( children: [ Expanded( child: DatePicker( value: form.date, onChanged: (DateTime? date) { if (date != null) { form.setDate(date); form.setSavedTimestamp(null); form.saveFormData(); session.onSubmit( changedField: "datetime", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); } }, ), ), TimePicker( value: form.time, onChanged: (TimeOfDay? time) { if (time != null) { form.setTime(time); form.setSavedTimestamp(null); form.saveFormData(); session.onSubmit( changedField: "datetime", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); } }, ), if (form.savedTimestamp == null) Button.secondary( child: Text( "Now" ), onPressed: () { form.setDate(DateTime.now()); form.setTime(TimeOfDay.now()); form.saveFormData(); session.onSubmit( changedField: "datetime", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), if (form.savedTimestamp != null) Button.primary( child: Text( "Undo" ), onPressed: () { DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(form.savedTimestamp!); form.setDate(DateTime(dateTime.year, dateTime.month, dateTime.day)); form.setTime(TimeOfDay(hour: dateTime.hour, minute: dateTime.minute)); form.setSavedTimestamp(null); session.onSubmit( changedField: "datetime", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ) ] ), ], ), ).withPadding( horizontal: 14 ), Gap(14), ConstrainedBox( constraints: BoxConstraints( minWidth: double.infinity, minHeight: 150, ), child: form.postImage == null ? CardButton( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.plus), Text("Add Media").xSmall.semiBold ], ), onPressed: () async { Uint8List? mediaBytes = await pickMediaFile(); if (mediaBytes != null) { if (!mounted) return; form.setPostImage(mediaBytes); form.saveFormData(); session.onSubmit( changedField: "post_image", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); } }, ) : OutlinedContainer( child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack( children: [ if (_isVideoBytes(form.postImage!)) ...[ AspectRatio( aspectRatio: 16 / 9, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.video, size: 48, ), Gap(8), Text("Video uploaded").small.semiBold, ], ), ), ) ] else ...[ Image.memory( form.postImage!, fit: BoxFit.cover, ), ], Positioned.fill( child: Align( alignment: Alignment.bottomRight, child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton.secondary( icon: Icon( LucideIcons.pen, ), onPressed: () async { Uint8List? imageBytes = await pickImageFile(); if (imageBytes != null) { if (!mounted) return; form.setPostImage(imageBytes); form.saveFormData(); session.onSubmit( changedField: "post_image", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); } }, ), Gap(8), IconButton.destructive( icon: Icon( LucideIcons.trash2, ), onPressed: () { form.setPostImage(null); form.saveFormData(); session.onSubmit( changedField: "post_image", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ], ), ).withPadding( all: 6 ), ) ], ), ), ), ).withPadding( horizontal: 14 ), Gap(14), SizedBox( width: double.infinity, child: ButtonGroup.horizontal( children: [ Expanded( child: ButtonGroup.vertical( children: [ ButtonGroup.horizontal( children: [ Expanded( child: TextField( features: [ InputFeature.leading( Icon( LucideIcons.messageCircle ), ) ], keyboardType: TextInputType.number, inputFormatters: [ NumberTextInputFormatter(), ], controller: _repliesController, onChanged: (value) { form.setReplies(value); form.fillEmptyEngagement(); _syncEngagementControllers(form); form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ), Expanded( child: TextField( features: [ InputFeature.leading( Icon( LucideIcons.repeat ), ) ], keyboardType: TextInputType.number, inputFormatters: [ NumberTextInputFormatter(), ], controller: _retweetsController, onChanged: (value) { form.setRetweets(value); form.fillEmptyEngagement(); _syncEngagementControllers(form); form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ), ], ), ButtonGroup.horizontal( children: [ Expanded( child: TextField( features: [ InputFeature.leading( Icon( LucideIcons.heart ), ) ], keyboardType: TextInputType.number, inputFormatters: [ NumberTextInputFormatter(), ], controller: _likesController, onChanged: (value) { form.setLikes(value); form.fillEmptyEngagement(); _syncEngagementControllers(form); form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ), Expanded( child: TextField( features: [ InputFeature.leading( Icon( LucideIcons.chartColumn ), ) ], keyboardType: TextInputType.number, inputFormatters: [ NumberTextInputFormatter(), ], controller: _viewsController, onChanged: (value) { form.setViews(value); form.fillEmptyEngagement(); _syncEngagementControllers(form); form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, ), ), ], ) ], ), ), Button.primary( onPressed: () { Random random = Random(); final formatter = NumberFormat("#,###"); final replies = formatter.format(random.nextInt(10000)); final retweets = formatter.format(random.nextInt(50000)); final likes = formatter.format(random.nextInt(100000)); final views = formatter.format(random.nextInt(1000000)); form.setReplies(replies); form.setRetweets(retweets); form.setLikes(likes); form.setViews(views); _repliesController.text = replies; _retweetsController.text = retweets; _likesController.text = likes; _viewsController.text = views; form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, child: Icon( LucideIcons.dices ), ), Button.destructive( onPressed: () { form.setReplies(""); form.setRetweets(""); form.setLikes(""); form.setViews(""); _repliesController.clear(); _retweetsController.clear(); _likesController.clear(); _viewsController.clear(); form.saveFormData(); session.onSubmit( changedField: "engagement", request: form.buildFullRequest(), onDebounceSettled: render.clearRender, ); }, child: Icon( LucideIcons.trash2 ), ) ], ), ).withPadding( horizontal: 14 ), ], ); } // sync controller text for fields that fillEmptyEngagement may have auto-filled void _syncEngagementControllers(FormProvider form) { if (_repliesController.text != form.replies) _repliesController.text = form.replies; if (_retweetsController.text != form.retweets) _retweetsController.text = form.retweets; if (_likesController.text != form.likes) _likesController.text = form.likes; if (_viewsController.text != form.views) _viewsController.text = form.views; } // pick image from file system Future pickImageFile() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: false, ); if (result != null) { Uint8List? imageBytes; // on web, bytes is availble directly if (result.files.single.bytes != null) { imageBytes = result.files.single.bytes!; } // on mobile (iOS/Android), need to raed from path else if (result.files.single.path != null) { File file = File(result.files.single.path!); imageBytes = await file.readAsBytes(); } return imageBytes; } return null; } // pick video from file system Future pickVideoFile() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.video, allowMultiple: false, ); if (result != null) { Uint8List? videoBytes; // on web, bytes is availble directly if (result.files.single.bytes != null) { videoBytes = result.files.single.bytes!; } // on mobile (iOS/Android), need to raed from path else if (result.files.single.path != null) { File file = File(result.files.single.path!); videoBytes = await file.readAsBytes(); } return videoBytes; } return null; } // pick image or video from file system Future pickMediaFile() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.media, allowMultiple: false, ); if (result != null) { if (result.files.single.bytes != null) { return result.files.single.bytes!; } else if (result.files.single.path != null) { File file = File(result.files.single.path!); return await file.readAsBytes(); } } return null; } // check if bytes are a video based on magic bytes bool _isVideoBytes(Uint8List bytes) { if (bytes.length < 12) return false; // MP4: starts with ftyp box if (bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70) { return true; } // WebM: starts with 0x1A45DFA3 if (bytes[0] == 0x1A && bytes[1] == 0x45 && bytes[2] == 0xDF && bytes[3] == 0xA3) { return true; } return false; } // resize image to reduce payload size without comression Future compressImage(Uint8List bytes, {int maxWidth = 800, int quality = 85}) async { try { img.Image? image = img.decodeImage(bytes); if (image == null) return null; // resize if image is larger than maxWidth if (image.width > maxWidth) { image = img.copyResize(image, width: maxWidth); } // encode as PNG (lossles, no compression) return Uint8List.fromList(img.encodePng(image)); } catch (e) { print("Error resizng image: $e"); return null; } } Future shareMedia() async { final render = context.read(); if (render.postPreviewImage == null) { print("No media to share"); return; } try { bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; final size = MediaQuery.of(context).size; final tempDir = await getTemporaryDirectory(); final fileExt = isVideo ? "mp4" : "png"; final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}.$fileExt"; final filePath = path.join(tempDir.path, fileName); final file = File(filePath); await file.writeAsBytes(render.postPreviewImage!); final result = await Share.shareXFiles( [XFile(filePath)], sharePositionOrigin: Rect.fromCenter( center: Offset(size.width / 2, size.height / 2), width: 1, height: 1, ), ); if (result.status == ShareResultStatus.success) { print("Share succesful"); } } catch (e) { print("Error sharin media: $e"); } } Future downloadMedia() async { final render = context.read(); if (render.postPreviewImage == null) return; try { bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; final ext = isVideo ? "mp4" : "png"; final name = "tweetforge_${DateTime.now().millisecondsSinceEpoch}"; await FileSaver.instance.saveFile( name: name, bytes: render.postPreviewImage!, ext: ext, mimeType: isVideo ? MimeType.mp4Video : MimeType.png, ); } catch (e) { print("Error downloading media: $e"); } } }