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 createState() => _HomePageState(); } class _HomePageState extends State { bool _isLoading = true; Map 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 _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 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 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 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 _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 _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"); } } }