1336 lines
44 KiB
Dart
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");
|
|
}
|
|
}
|
|
} |