Files
Quote-Generator-Client/lib/pages/home/page.dart
ImBenji 7a88585b6e
Some checks failed
Build Android App / build (push) Failing after 32m54s
initial
2026-01-02 12:05:49 +00:00

1100 lines
35 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/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: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';
// 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": "",
"timestamp": 0,
"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;
if (isWideLayout) {
return Scaffold(
child: Center(
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Gap(max(topPadding, 14)),
SizedBox(
height: 600,
child: _buildPreview(context)
),
Gap(14),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 400,
),
child: _buildForm(context)
),
Gap(max(bottomPadding, 14)),
],
),
),
),
);
}
return Scaffold(
child: SingleChildScrollView(
child: Column(
children: [
Gap(max(topPadding, 14)),
_buildPreview(context),
Gap(14),
_buildForm(context),
Gap(max(bottomPadding, 14)),
],
),
),
);
}
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: IconButton.secondary(
icon: Icon(
Icons.share,
),
onPressed: () async {
await shareImage();
}
)
).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"
),
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"
),
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["timestamp"] > 0
? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"])
: DateTime.now(),
onChanged: (DateTime? date) {
if (date != null) {
setState(() {
formData["timestamp"] = date.millisecondsSinceEpoch;
});
_saveFormData();
onSubmit(changedField: "timestamp");
}
},
),
),
TimePicker(
value: formData["timestamp"] > 0
? TimeOfDay.fromDateTime(DateTime.fromMillisecondsSinceEpoch(formData["timestamp"]))
: TimeOfDay.now(),
onChanged: (TimeOfDay? time) {
if (time != null) {
DateTime currentDate = formData["timestamp"] > 0
? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"])
: DateTime.now();
DateTime newDateTime = DateTime(
currentDate.year,
currentDate.month,
currentDate.day,
time.hour,
time.minute,
);
setState(() {
formData["timestamp"] = newDateTime.millisecondsSinceEpoch;
});
_saveFormData();
onSubmit(changedField: "timestamp");
}
},
)
]
),
],
),
).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;
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");
if (!formData["handle"].startsWith("@")) {
formData["handle"] = "@" + formData["handle"];
}
// 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: formData["timestamp"],
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 == "timestamp" ? formData["timestamp"] : 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");
}
} catch (e) {
print("Error sharin image: $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);
}
print("Loaded form data: name=$displayName, handle=$handle, content=$content");
setState(() {
formData["display_name"] = displayName;
formData["handle"] = handle;
formData["content"] = content;
formData["timestamp"] = timestamp;
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),
);
}
// fix handle format
if (handle.isNotEmpty && !handle.startsWith("@")) {
handle = "@$handle";
}
// create session with full form data
QuoteSessionRequest initialRequest = QuoteSessionRequest(
displayName: displayName,
username: handle.toLowerCase(),
text: content,
timestamp: timestamp,
verified: verified,
avatarUrl: avatarImage,
imageUrl: postImage,
engagement: engagement,
);
await _initializeSession(initialRequest);
// genrate preview image if we have data
if (_currentSessionId != null && (displayName.isNotEmpty || content.isNotEmpty)) {
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"]}");
// 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", formData["timestamp"]);
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");
}
}
}