1364 lines
45 KiB
Dart
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");
|
|
}
|
|
}
|
|
}
|