Files
Quote-Generator-Client/lib/pages/home/page.dart
ImBenji b66b73a3c6
Some checks failed
Build Android App / build (push) Failing after 43s
update base URL for QuoteGeneratorApiV2 to use localhost in debug mode
2026-01-02 21:01:35 +00:00

1336 lines
44 KiB
Dart

import 'dart:convert';
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:quotegen_client/services/quote_api_v2.dart';
import 'package:quotegen_client/widgets/ad_banner.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:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
import 'package:file_saver/file_saver.dart';
// import 'package:http/http.dart' as http;
// 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> {
bool _isLoading = true;
Map<String, dynamic> formData = {
"display_name": "",
"handle": "",
"content": "",
"date": DateTime.now(),
"time": TimeOfDay.now(),
"avatar_image": null,
"post_image": null,
"replies": "",
"retweets": "",
"likes": "",
"views": "",
};
late TextEditingController _repliesController;
late TextEditingController _retweetsController;
late TextEditingController _likesController;
late TextEditingController _viewsController;
Uint8List? postPreviewImage;
@override
void initState() {
super.initState();
_repliesController = TextEditingController();
_retweetsController = TextEditingController();
_likesController = TextEditingController();
_viewsController = TextEditingController();
_loadFormData();
}
@override
void dispose() {
_repliesController.dispose();
_retweetsController.dispose();
_likesController.dispose();
_viewsController.dispose();
super.dispose();
}
Future<void> _initializeSession(QuoteSessionRequest initialData) async {
try {
QuoteSession session = await QuoteGeneratorApiV2.createSession(initialData);
_currentSessionId = session.id;
print("Session created: ${session.id}");
} catch (e) {
print("Failed to creat session: $e");
}
}
bool get isWideLayout {
return MediaQuery.of(context).size.width >= 800;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
child: Center(
child: CircularProgressIndicator(),
),
);
}
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
// Check if we're on mobile (iOS/Android)
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(
height: 600,
child: _buildPreview(context)
),
Gap(14),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildForm(context)),
Gap(14),
AdBanner(),
],
),
),
],
),
),
),
);
}
// Mobile layout content
Widget mainContent = SingleChildScrollView(
child: Column(
children: [
Gap(max(topPadding, 14)),
_buildPreview(context),
Gap(14),
_buildForm(context),
Gap(14),
// Add bottom padding on mobile to avoid content being hidden behind ad
if (isMobilePlatform)
Gap(100), // Space for the overlay ad (80px min height + padding)
// Only show inline ad on non-mobile platforms
if (!isMobilePlatform)
AdBanner(),
Gap(max(bottomPadding, 14)),
],
),
);
// Wrap in Stack with overlay ad for mobile platforms
if (isMobilePlatform) {
return Scaffold(
child: Stack(
children: [
mainContent,
// Bottom overlay ad for mobile
Positioned(
left: 0,
right: 0,
bottom: max(bottomPadding, 0),
child: AdBanner(),
),
],
),
);
}
return Scaffold(
child: mainContent,
);
}
Widget _buildPreview(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: OutlinedContainer(
backgroundColor: Colors.black,
child: postPreviewImage == null ? Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
) : Stack(
children: [
Image.memory(
postPreviewImage!,
fit: BoxFit.cover,
),
Align(
alignment: Alignment.topRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ([TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.macOS].contains(defaultTargetPlatform))...[
IconButton.secondary(
icon: Icon(
Icons.share,
),
onPressed: () async {
await shareImage();
}
),
Gap(8),
],
IconButton.secondary(
icon: Icon(
LucideIcons.download,
),
onPressed: () async {
if (postPreviewImage == null) {
print("No image avalable to download");
return;
}
try {
// genrate filename with timestmp
final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}";
// On desktop platforms (macOS, Windows, Linux), use FilePicker to let user choose location
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
String? outputPath = await FilePicker.platform.saveFile(
dialogTitle: "Save Quote Image",
fileName: "$fileName.png",
type: FileType.image,
);
if (outputPath != null) {
final file = File(outputPath);
await file.writeAsBytes(postPreviewImage!);
print("Image saved succesfully to: $outputPath");
}
} else {
// For mobile (iOS/Android) and web, use FileSaver
await FileSaver.instance.saveFile(
name: fileName,
bytes: postPreviewImage!,
ext: "png",
mimeType: MimeType.png,
);
print("Image downloaded succesfully");
}
} catch (e) {
print("Error downloadin image: $e");
}
}
),
Gap(8),
IconButton.outline(
icon: Icon(
LucideIcons.link
),
onPressed: () async {
if (_currentSessionId == null) {
print("No session available");
return;
}
try {
// create snapshot link
QuoteSnapshot snapshot = await QuoteGeneratorApiV2.createSnapshotLink(_currentSessionId!);
// copy URL to clipboard
await Clipboard.setData(ClipboardData(text: snapshot.url));
if (!mounted) return;
// show toast notification
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
title: const Text('Snapshot Link Copied'),
subtitle: const Text('The snapshot link has been copied to clipboard'),
trailingAlignment: Alignment.center,
),
);
}
);
print("Snapshot link created: ${snapshot.url}");
} catch (e) {
print("Error creating snapshot link: $e");
if (!mounted) return;
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
title: const Text('Error'),
subtitle: const Text('Failed to create snapshot link'),
trailingAlignment: Alignment.center,
),
);
}
);
}
},
)
].reversed.toList(),
)
).withPadding(
all: 12
)
],
),
)
).withPadding(
horizontal: 14
);
}
Widget _buildForm(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: isWideLayout ? 72 : 90,
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: formData["display_name"],
onChanged: (value) {
setState(() {
formData["display_name"] = value;
});
_saveFormData();
onSubmit(changedField: "display_name");
},
),
),
VerticalDivider(),
SizedBox(
height: 10,
child: Toggle(
value: formData["verified"] ?? false,
child: Icon(
LucideIcons.badgeCheck,
),
style: ButtonStyle.primaryIcon(),
onChanged: (value) {
setState(() {
formData["verified"] = value;
});
_saveFormData();
onSubmit(changedField: "verified");
},
),
)
],
),
TextField(
placeholder: Text(
"Handle"
),
features: [
InputFeature.leading(
Icon(
LucideIcons.atSign
)
)
],
initialValue: formData["handle"],
onChanged: (value) {
setState(() {
formData["handle"] = value;
});
_saveFormData();
onSubmit(changedField: "handle");
},
)
],
)
),
Gap(14),
AspectRatio(
aspectRatio: 1,
child: formData["avatar_image"] == null ? Button.card(
onPressed: () async {
Uint8List? imageBytes = await pickImageFile();
if (imageBytes != null) {
// downscale avatar image
Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400);
if (!mounted) return;
setState(() {
formData["avatar_image"] = resizedBytes ?? imageBytes;
});
_saveFormData();
onSubmit(changedField: "avatar_image");
}
},
child: FittedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
LucideIcons.plus
),
Gap(4),
Text(
"Avatar\nImage",
textAlign: TextAlign.center,
).xSmall.semiBold
],
),
),
) : GestureDetector(
onTap: () {
if (!mounted) return;
setState(() {
formData["avatar_image"] = null;
});
_saveFormData();
onSubmit(changedField: "avatar_image");
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Image.memory(
formData["avatar_image"],
fit: BoxFit.cover,
),
Center(
child: Icon(
LucideIcons.trash2,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.9),
blurRadius: 6,
)
],
),
)
],
),
),
),
)
],
),
).withPadding(
horizontal: 14
),
Gap(14),
SizedBox(
width: double.infinity,
child: ButtonGroup.vertical(
children: [
TextArea(
placeholder: Text(
"Content"
),
initialValue: formData["content"],
onChanged: (value) {
setState(() {
formData["content"] = value;
});
_saveFormData();
onSubmit(changedField: "content");
},
expands: false,
expandableHeight: false,
expandableWidth: false,
textAlignVertical: TextAlignVertical.top,
),
ButtonGroup.horizontal(
children: [
Expanded(
child: DatePicker(
value: formData["date"],
onChanged: (DateTime? date) {
if (date != null) {
setState(() {
formData["date"] = date;
});
_saveFormData();
onSubmit(changedField: "datetime");
}
},
),
),
TimePicker(
value: formData["time"],
onChanged: (TimeOfDay? time) {
if (time != null) {
setState(() {
formData["time"] = time;
});
_saveFormData();
onSubmit(changedField: "datetime");
}
},
),
Button.secondary(
child: Text(
"Now"
),
onPressed: () {
DateTime now = DateTime.now();
TimeOfDay nowTime = TimeOfDay.now();
setState(() {
formData["date"] = now;
formData["time"] = nowTime;
});
_saveFormData();
onSubmit(changedField: "datetime");
},
)
]
),
],
),
).withPadding(
horizontal: 14
),
Gap(14),
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity,
minHeight: 150,
),
child: formData["post_image"] == null ? CardButton(
child: Column(
children: [
Icon(
LucideIcons.plus
),
Text(
"Add Image"
).xSmall.semiBold
],
),
onPressed: () async {
Uint8List? imageBytes = await pickImageFile();
if (imageBytes != null) {
if (!mounted) return;
setState(() {
formData["post_image"] = imageBytes;
});
_saveFormData();
onSubmit(changedField: "post_image");
}
},
) : ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Image.memory(
formData["post_image"],
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;
setState(() {
formData["post_image"] = imageBytes;
});
_saveFormData();
onSubmit(changedField: "post_image");
}
},
),
Gap(8),
IconButton.destructive(
icon: Icon(
LucideIcons.trash2,
),
onPressed: () {
if (!mounted) return;
setState(() {
formData["post_image"] = null;
});
_saveFormData();
onSubmit(changedField: "post_image");
},
),
],
),
).withPadding(
all: 4
),
)
],
),
),
).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) {
setState(() {
formData["replies"] = value;
_fillEmptyEngagement();
});
_saveFormData();
onSubmit(changedField: "engagement");
},
),
),
Expanded(
child: TextField(
features: [
InputFeature.leading(
Icon(
LucideIcons.repeat
),
)
],
keyboardType: TextInputType.number,
inputFormatters: [
NumberTextInputFormatter(),
],
controller: _retweetsController,
onChanged: (value) {
setState(() {
formData["retweets"] = value;
_fillEmptyEngagement();
});
_saveFormData();
onSubmit(changedField: "engagement");
},
),
),
],
),
ButtonGroup.horizontal(
children: [
Expanded(
child: TextField(
features: [
InputFeature.leading(
Icon(
LucideIcons.heart
),
)
],
keyboardType: TextInputType.number,
inputFormatters: [
NumberTextInputFormatter(),
],
controller: _likesController,
onChanged: (value) {
setState(() {
formData["likes"] = value;
_fillEmptyEngagement();
});
_saveFormData();
onSubmit(changedField: "engagement");
},
),
),
Expanded(
child: TextField(
features: [
InputFeature.leading(
Icon(
LucideIcons.chartColumn
),
)
],
keyboardType: TextInputType.number,
inputFormatters: [
NumberTextInputFormatter(),
],
controller: _viewsController,
onChanged: (value) {
setState(() {
formData["views"] = value;
_fillEmptyEngagement();
});
_saveFormData();
onSubmit(changedField: "engagement");
},
),
),
],
)
],
),
),
Button.primary(
onPressed: () {
// randomize engagement metrics
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));
setState(() {
formData["replies"] = replies;
formData["retweets"] = retweets;
formData["likes"] = likes;
formData["views"] = views;
_repliesController.text = replies;
_retweetsController.text = retweets;
_likesController.text = likes;
_viewsController.text = views;
});
_saveFormData();
onSubmit(changedField: "engagement");
},
child: Icon(
LucideIcons.dices
),
),
Button.destructive(
onPressed: () {
setState(() {
formData["replies"] = "";
formData["retweets"] = "";
formData["likes"] = "";
formData["views"] = "";
_repliesController.clear();
_retweetsController.clear();
_likesController.clear();
_viewsController.clear();
});
_saveFormData();
onSubmit(changedField: "engagement");
},
child: Icon(
LucideIcons.trash2
),
)
],
),
).withPadding(
horizontal: 14
),
],
);
}
// 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;
}
// 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;
}
}
int _submitCount = 0;
String? _currentSessionId;
// combine date and time into unix timestamp (in seconds for API)
int _combineDateTime() {
DateTime date = formData["date"];
TimeOfDay time = formData["time"];
DateTime combined = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
return combined.millisecondsSinceEpoch ~/ 1000;
}
void onSubmit({String? changedField}) async {
int currentSubmit = ++_submitCount;
await Future.delayed(Duration(milliseconds: 700));
// Only proceed if this is the latest submit
if (currentSubmit != _submitCount) return;
if (!mounted) return;
setState(() {
postPreviewImage = null;
});
print("Form Data Submitted: $formData");
// Wait for session to be ready
while (_currentSessionId == null) {
await Future.delayed(Duration(milliseconds: 100));
}
// Build request with only changed field
QuoteSessionRequest request;
if (changedField == null) {
// Initial load - send everything
QuoteEngagement? engagement;
if (formData["likes"] != "" || formData["retweets"] != "" ||
formData["replies"] != "" || formData["views"] != "") {
engagement = QuoteEngagement(
likes: _parseNum(formData["likes"]),
retweets: _parseNum(formData["retweets"]),
replies: _parseNum(formData["replies"]),
views: _parseNum(formData["views"]),
);
}
request = QuoteSessionRequest(
displayName: formData["display_name"],
username: formData["handle"].toLowerCase(),
text: formData["content"],
timestamp: _combineDateTime(),
avatarUrl: formData["avatar_image"],
imageUrl: formData["post_image"],
verified: formData["verified"] ?? false,
engagement: engagement,
);
} else {
// Only send the changed field
final engagement = (changedField == "engagement" || changedField?.startsWith("engagement_") == true) ? _buildEngagement(changedField!) : null;
final shouldClearEngagement = changedField == "engagement" && engagement == null;
request = QuoteSessionRequest(
displayName: changedField == "display_name" ? formData["display_name"] : null,
username: changedField == "handle" ? formData["handle"].toLowerCase() : null,
text: changedField == "content" ? formData["content"] : null,
timestamp: changedField == "datetime" ? _combineDateTime() : null,
avatarUrl: changedField == "avatar_image" ? formData["avatar_image"] : null,
imageUrl: changedField == "post_image" ? formData["post_image"] : null,
verified: changedField == "verified" ? (formData["verified"] ?? false) : null,
engagement: engagement,
clearAvatarUrl: changedField == "avatar_image" && formData["avatar_image"] == null,
clearImageUrl: changedField == "post_image" && formData["post_image"] == null,
clearEngagement: shouldClearEngagement,
);
}
// Update session and generate image
await QuoteGeneratorApiV2.updateSession(_currentSessionId!, request);
Uint8List imageBytes = await QuoteGeneratorApiV2.generateImage(_currentSessionId!);
if (!mounted) return;
setState(() {
postPreviewImage = imageBytes;
});
}
// parse number string, stripping commas first
int? _parseNum(String val) {
if (val.isEmpty) return null;
return int.tryParse(val.replaceAll(",", ""));
}
// fill empty engagment fields with 1 if any field has value
// returns true if any fields were auto-filled
bool _fillEmptyEngagement() {
bool hasValue = formData["replies"] != "" || formData["retweets"] != "" ||
formData["likes"] != "" || formData["views"] != "";
bool didFill = false;
if (hasValue) {
if (formData["replies"] == "") {
formData["replies"] = "1";
_repliesController.text = "1";
didFill = true;
}
if (formData["retweets"] == "") {
formData["retweets"] = "1";
_retweetsController.text = "1";
didFill = true;
}
if (formData["likes"] == "") {
formData["likes"] = "1";
_likesController.text = "1";
didFill = true;
}
if (formData["views"] == "") {
formData["views"] = "1";
_viewsController.text = "1";
didFill = true;
}
}
return didFill;
}
QuoteEngagement? _buildEngagement(String field) {
// If field is "engagement", send all engagement fields (randomize button)
if (field == "engagement") {
// if all fields are empty, return null
if (formData["likes"] == "" && formData["retweets"] == "" &&
formData["replies"] == "" && formData["views"] == "") {
return null;
}
return QuoteEngagement(
likes: _parseNum(formData["likes"]),
retweets: _parseNum(formData["retweets"]),
replies: _parseNum(formData["replies"]),
views: _parseNum(formData["views"]),
);
}
// Otherwise only send the specific engagement field that changed
return QuoteEngagement(
likes: field == "engagement_likes" ? _parseNum(formData["likes"]) : null,
retweets: field == "engagement_retweets" ? _parseNum(formData["retweets"]) : null,
replies: field == "engagement_replies" ? _parseNum(formData["replies"]) : null,
views: field == "engagement_views" ? _parseNum(formData["views"]) : null,
);
}
Future<void> shareImage() async {
if (postPreviewImage == null) {
print("No image to share");
return;
}
try {
// get temporary directry
final tempDir = await getTemporaryDirectory();
// create a unique filename with timestmp
final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}.png";
final filePath = path.join(tempDir.path, fileName);
// write the image bytes to a temporry file
final file = File(filePath);
await file.writeAsBytes(postPreviewImage!);
// Share the file using the systm share dialog
final result = await Share.shareXFiles(
[XFile(filePath)],
text: "Check out this quote!",
);
// optinal: log share result
if (result.status == ShareResultStatus.success) {
print("Share succesful");
// Send Discord webhook notifiation
// _sendShareNotification();
}
} catch (e) {
print("Error sharin image: $e");
}
}
// Future<void> _sendShareNotification() async {
// const webhookUrl = "https://discord.com/api/webhooks/1456729319408664814/p2Ctqi7BijV0c34V-BnB62m6_yjkf72eD64oJyxXaNAcqZNfFm_-bA2uPZg1NTKpVdxp";
//
// try {
// final response = await http.post(
// Uri.parse(webhookUrl),
// headers: {"Content-Type": "application/json"},
// body: jsonEncode({
// "content": "Someone just shared a quote from the app! 🎉",
// "embeds": [
// {
// "title": "Quote Shared",
// "description": formData["content"].isNotEmpty
// ? "\"${formData["content"]}\""
// : "A quote was shared",
// "color": 5814783,
// "fields": [
// {
// "name": "User",
// "value": formData["display_name"].isNotEmpty
// ? "${formData["display_name"]} (@${formData["handle"]})"
// : "Anonymous",
// "inline": true
// },
// {
// "name": "Timestamp",
// "value": DateTime.now().toIso8601String(),
// "inline": true
// }
// ],
// "footer": {
// "text": "QuoteGen App"
// }
// }
// ]
// }),
// );
//
// if (response.statusCode == 204 || response.statusCode == 200) {
// print("Discord notificaton sent successfuly");
// } else {
// print("Failed to send Discord notification: ${response.statusCode}");
// }
// } catch (e) {
// print("Error sendin Discord webhook: $e");
// }
// }
// load form data from shared preferences
Future<void> _loadFormData() async {
try {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
// load text fields
final displayName = prefs.getString("display_name") ?? "";
var handle = prefs.getString("handle") ?? "";
final content = prefs.getString("content") ?? "";
final timestamp = prefs.getInt("timestamp") ?? 0;
final replies = prefs.getString("replies") ?? "";
final retweets = prefs.getString("retweets") ?? "";
final likes = prefs.getString("likes") ?? "";
final views = prefs.getString("views") ?? "";
final verified = prefs.getBool("verified") ?? false;
// load images from base64
Uint8List? avatarImage;
Uint8List? postImage;
String? avatarBase64 = prefs.getString("avatar_image");
if (avatarBase64 != null && avatarBase64.isNotEmpty) {
avatarImage = base64Decode(avatarBase64);
}
String? postBase64 = prefs.getString("post_image");
if (postBase64 != null && postBase64.isNotEmpty) {
postImage = base64Decode(postBase64);
}
// split timestamp into seperate date and time
DateTime dateTime = timestamp > 0
? DateTime.fromMillisecondsSinceEpoch(timestamp)
: DateTime.now();
print("Loaded form data: name=$displayName, handle=$handle, content=$content");
setState(() {
formData["display_name"] = displayName;
formData["handle"] = handle;
formData["content"] = content;
formData["date"] = DateTime(dateTime.year, dateTime.month, dateTime.day);
formData["time"] = TimeOfDay(hour: dateTime.hour, minute: dateTime.minute);
formData["verified"] = verified;
formData["replies"] = replies;
formData["retweets"] = retweets;
formData["likes"] = likes;
formData["views"] = views;
formData["avatar_image"] = avatarImage;
formData["post_image"] = postImage;
// Set controller text
_repliesController.text = replies;
_retweetsController.text = retweets;
_likesController.text = likes;
_viewsController.text = views;
});
// build engagment object if we have any values
QuoteEngagement? engagement;
if (likes != "" || retweets != "" || replies != "" || views != "") {
engagement = QuoteEngagement(
likes: _parseNum(likes),
retweets: _parseNum(retweets),
replies: _parseNum(replies),
views: _parseNum(views),
);
}
// create session with full form data (API expects timestamp in seconds)
// Use _combineDateTime() to get timestamp from formData (defaults to now if nothing saved)
QuoteSessionRequest initialRequest = QuoteSessionRequest(
displayName: displayName,
username: handle.toLowerCase(),
text: content,
timestamp: _combineDateTime(),
verified: verified,
avatarUrl: avatarImage,
imageUrl: postImage,
engagement: engagement,
);
await _initializeSession(initialRequest);
// genrate preview image if we have session
if (_currentSessionId != null) {
Uint8List imageBytes = await QuoteGeneratorApiV2.generateImage(_currentSessionId!);
if (!mounted) return;
setState(() {
postPreviewImage = imageBytes;
});
}
} catch (e) {
print("Error loading form data: $e");
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// save form data to shared preferences
Future<void> _saveFormData() async {
try {
final prefs = await SharedPreferences.getInstance();
print("Saving form data: name=${formData["display_name"]}, handle=${formData["handle"]}, content=${formData["content"]}");
// combine date and time into timestamp (in milliseconds for storage)
int timestampMillis = _combineDateTime() * 1000;
// save text felds
await prefs.setString("display_name", formData["display_name"]);
await prefs.setString("handle", formData["handle"]);
await prefs.setString("content", formData["content"]);
await prefs.setInt("timestamp", timestampMillis);
await prefs.setString("replies", formData["replies"]);
await prefs.setString("retweets", formData["retweets"]);
await prefs.setString("likes", formData["likes"]);
await prefs.setString("views", formData["views"]);
await prefs.setBool("verified", formData["verified"] ?? false);
// save images as base64
if (formData["avatar_image"] != null) {
String avatarBase64 = base64Encode(formData["avatar_image"]);
await prefs.setString("avatar_image", avatarBase64);
} else {
await prefs.remove("avatar_image");
}
if (formData["post_image"] != null) {
String postBase64 = base64Encode(formData["post_image"]);
await prefs.setString("post_image", postBase64);
} else {
await prefs.remove("post_image");
}
print("Form data saved successfully");
} catch (e) {
print("Error savin form data: $e");
}
}
}