Files
Quote-Generator-Client/lib/pages/home/page.dart
T
2026-08-25 11:46:33 +01:00

1364 lines
45 KiB
Dart

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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
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<FormProvider>();
final session = context.read<SessionProvider>();
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<SessionProvider>();
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<FormProvider>();
final session = context.watch<SessionProvider>();
final render = context.watch<RenderProvider>();
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<GalleryProvider>().saveCurrentForm(context.read<FormProvider>());
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<SessionProvider>();
final r = context.read<RenderProvider>();
final f = context.read<FormProvider>();
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<GalleryProvider>().saveCurrentForm(context.read<FormProvider>());
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<SessionProvider>();
final r = context.read<RenderProvider>();
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<SessionProvider>();
final render = context.watch<RenderProvider>();
final form = context.watch<FormProvider>();
final backend = context.watch<BackendStatusProvider>();
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<FormProvider>();
final session = context.watch<SessionProvider>();
final render = context.watch<RenderProvider>();
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<Uint8List?> 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<Uint8List?> 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<Uint8List?> 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<Uint8List?> 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<void> shareMedia() async {
final render = context.read<RenderProvider>();
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<void> downloadMedia() async {
final render = context.read<RenderProvider>();
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");
}
}
}