From 7a88585b6e6ad9288f7cd1ba812d52c3899b2235 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Fri, 2 Jan 2026 12:05:49 +0000 Subject: [PATCH] initial --- .gitea/workflows/android-build.yml | 1 + devtools_options.yaml | 3 + lib/pages/home/page.dart | 1264 ++++++++++++----- lib/services/quote_api.dart | 40 +- lib/services/quote_api_v2.dart | 251 ++++ macos/Podfile.lock | 42 + macos/Runner.xcodeproj/project.pbxproj | 98 +- .../contents.xcworkspacedata | 3 + macos/Runner/DebugProfile.entitlements | 4 + macos/Runner/Release.entitlements | 4 + pubspec.lock | 8 + pubspec.yaml | 5 + 12 files changed, 1356 insertions(+), 367 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/services/quote_api_v2.dart create mode 100644 macos/Podfile.lock diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index b24ab2e..373d1f3 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -47,6 +47,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: "beta" + cache: true - name: Fix flutter git ownership run: git config --global --add safe.directory '*' diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/pages/home/page.dart b/lib/pages/home/page.dart index 7cf1820..b5ba6f6 100644 --- a/lib/pages/home/page.dart +++ b/lib/pages/home/page.dart @@ -6,14 +6,47 @@ 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.dart'; +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 { @@ -28,6 +61,8 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { + bool _isLoading = true; + Map formData = { "display_name": "", "handle": "", @@ -35,375 +70,686 @@ class _HomePageState extends State { "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; - final TextEditingController _displayNameController = TextEditingController(); - final TextEditingController _handleController = TextEditingController(); - final TextEditingController _contentController = TextEditingController(); @override void initState() { super.initState(); + _repliesController = TextEditingController(); + _retweetsController = TextEditingController(); + _likesController = TextEditingController(); + _viewsController = TextEditingController(); _loadFormData(); } @override void dispose() { - _displayNameController.dispose(); - _handleController.dispose(); - _contentController.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)), - - 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.bottomRight, - child: IconButton.secondary( - icon: Icon( - Icons.share, - ), - onPressed: () async { - await shareImage(); - } - ) - ).withPadding( - all: 12 - ) - ], - ), - ) - ).withPadding( - horizontal: 14 - ), - + + _buildPreview(context), + Gap(14), - - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Display Name" - ).xSmall.semiBold, - - Gap(6), - - TextField( - controller: _displayNameController, - placeholder: Text( - "e.g. John Doe" - ), - onChanged: (value) { - setState(() { - formData["display_name"] = value; - }); - _saveFormData(); - onSubmit(); - }, - ), - ], - ), - ), - - Gap(14), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Handle" - ).xSmall.semiBold, - - Gap(6), - - TextField( - controller: _handleController, - placeholder: Text( - "e.g. @johndoe" - ), - onChanged: (value) { - setState(() { - formData["handle"] = value; - }); - _saveFormData(); - onSubmit(); - }, - ), - ], - ), - ), - ], - ), - - Gap(10), - - SizedBox( - height: 132, - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: double.infinity, - child: OutlineButton( - child: Text("Select Avatar Image"), - onPressed: () 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(); - } - - if (imageBytes != null) { - // downscale avatar image - Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400); - if (!mounted) return; - setState(() { - formData["avatar_image"] = resizedBytes ?? imageBytes; - }); - _saveFormData(); - onSubmit(); - } - } - }, - ), - ), - - Gap(8), - + _buildForm(context), - - Divider(), - - Gap(6), - - Text( - "Timestamp" - ).xSmall.semiBold, - - Gap(6), - - SizedBox( - width: double.infinity, - child: DatePicker( - value: formData["timestamp"] > 0 - ? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"]) - : DateTime.now(), - onChanged: (DateTime? date) { - setState(() { - formData["timestamp"] = date!.millisecondsSinceEpoch; - }); - _saveFormData(); - onSubmit(); - } - ) - ).withPadding( - right: 10 - ), - ] - ), - ), - - // Gap(10), - - VerticalDivider().withPadding( - top: 54 - ), - - Gap(10), - - AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(1000), - child: formData["avatar_image"] != null ? Image.memory( - formData["avatar_image"], - width: 50, - height: 50, - fit: BoxFit.cover, - ) : OutlinedContainer( - borderRadius: BorderRadius.circular(1000), - child: Center( - child: Text( - formData["display_name"].isNotEmpty ? formData["display_name"][0].toUpperCase() : "P", - ) - ), - ), - ) - ), - ], - ), - ), - - Gap(10), - - Text( - "Content" - ).xSmall.semiBold, - - Gap(6), - - TextArea( - controller: _contentController, - onChanged: (value) { - setState(() { - formData["content"] = value; - }); - _saveFormData(); - onSubmit(); - }, - ), - - Gap(10), - - Divider(), - - Gap(10), - - if (formData["post_image"] != null)...[ - OutlinedContainer( - child: formData["post_image"] != null ? Image.memory( - formData["post_image"], - width: double.infinity, - // height: 200, - fit: BoxFit.cover, - ) : Center( - child: Text( - "No Post Image Selected", - ).small.semiBold, - ), - ), - - Gap(10), - ], - - Row( - children: [ - Expanded( - child: SizedBox( - width: double.infinity, - child: OutlineButton( - child: formData["post_image"] == null ? Text("Select Post Image") : Text("Change Post Image"), - onPressed: () 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(); - } - - if (imageBytes != null) { - if (!mounted) return; - setState(() { - formData["post_image"] = imageBytes; - }); - _saveFormData(); - onSubmit(); - } - } - }, - ), - ), - ), - - if (formData["post_image"] != null)...[ - Gap(10), - - IconButton.destructive( - icon: Icon( - Icons.delete, - ), - onPressed: () { - if (!mounted) return; - setState(() { - formData["post_image"] = null; - }); - _saveFormData(); - onSubmit(); - }, - ) - ] - ], - ), - - Gap(bottomPadding) - - ], - ).withPadding( - horizontal: 14 - ) - + 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 { @@ -424,7 +770,9 @@ class _HomePageState extends State { } int _submitCount = 0; - void onSubmit() async { + String? _currentSessionId; + + void onSubmit({String? changedField}) async { int currentSubmit = ++_submitCount; @@ -444,16 +792,61 @@ class _HomePageState extends State { formData["handle"] = "@" + formData["handle"]; } - QuoteRequest request = QuoteRequest( - displayName: formData["display_name"], - username: formData["handle"].toLowerCase(), - text: formData["content"], - timestamp: formData["timestamp"], - avatarUrl: formData["avatar_image"], - imageUrl: formData["post_image"], - ); + // Wait for session to be ready + while (_currentSessionId == null) { + await Future.delayed(Duration(milliseconds: 100)); + } - Uint8List imageBytes = await QuoteGeneratorApi.generateQuotePost(request); + // 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(() { @@ -462,6 +855,72 @@ class _HomePageState extends State { } + // 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"); @@ -502,36 +961,101 @@ class _HomePageState extends State { 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(() { - // load text fields - formData["display_name"] = prefs.getString("display_name") ?? ""; - formData["handle"] = prefs.getString("handle") ?? ""; - formData["content"] = prefs.getString("content") ?? ""; - formData["timestamp"] = prefs.getInt("timestamp") ?? 0; + 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; - // load images from base64 - String? avatarBase64 = prefs.getString("avatar_image"); - if (avatarBase64 != null && avatarBase64.isNotEmpty) { - formData["avatar_image"] = base64Decode(avatarBase64); - } - - String? postBase64 = prefs.getString("post_image"); - if (postBase64 != null && postBase64.isNotEmpty) { - formData["post_image"] = base64Decode(postBase64); - } - - // update controllers - _displayNameController.text = formData["display_name"]; - _handleController.text = formData["handle"]; - _contentController.text = formData["content"]; + // Set controller text + _repliesController.text = replies; + _retweetsController.text = retweets; + _likesController.text = likes; + _viewsController.text = views; }); - // regenerate preview if we have data - if (formData["display_name"].isNotEmpty || formData["content"].isNotEmpty) { - onSubmit(); + // 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; + }); + } } } @@ -540,23 +1064,35 @@ class _HomePageState extends State { 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"); } diff --git a/lib/services/quote_api.dart b/lib/services/quote_api.dart index 481fdf9..a678096 100644 --- a/lib/services/quote_api.dart +++ b/lib/services/quote_api.dart @@ -3,6 +3,29 @@ import "dart:typed_data"; import "package:http/http.dart" as http; +class QuoteEngagement { + final int? likes; + final int? retweets; + final int? replies; + final int? views; + + QuoteEngagement({ + this.likes, + this.retweets, + this.replies, + this.views, + }); + + Map toJson() { + return { + if (likes != null) "likes": likes, + if (retweets != null) "retweets": retweets, + if (replies != null) "replies": replies, + if (views != null) "views": views, + }; + } +} + class QuoteRequest { final String? displayName; final String? username; @@ -10,6 +33,8 @@ class QuoteRequest { final String? text; final dynamic imageUrl; // can be String or Uint8List final int? timestamp; + final bool? verified; + final QuoteEngagement? engagement; QuoteRequest({ this.displayName, @@ -18,6 +43,8 @@ class QuoteRequest { this.text, this.imageUrl, this.timestamp, + this.verified, + this.engagement, }); @@ -40,6 +67,8 @@ class QuoteRequest { "text": text, "imageUrl": _encodeImage(imageUrl), "timestamp": timestamp, + if (verified != null) "verified": verified, + if (engagement != null) "engagement": engagement!.toJson(), }; } @@ -58,13 +87,20 @@ class QuoteRequest { if (timestamp != null) params["timestamp"] = timestamp.toString(); + if (verified != null) params["verified"] = verified.toString(); + + // engagment is complex obj, better suited for POST reqests + if (engagement != null) { + params["engagement"] = jsonEncode(engagement!.toJson()); + } + return Uri(queryParameters: params).query; } } class QuoteGeneratorApi { - static const String _baseUrl = "https://quotes.imbenji.net"; - // static const String _baseUrl = "http://localhost:3000"; + // static const String _baseUrl = "https://quotes.imbenji.net"; + static const String _baseUrl = "http://localhost:3000"; // genrate a quote image using POST diff --git a/lib/services/quote_api_v2.dart b/lib/services/quote_api_v2.dart new file mode 100644 index 0000000..cd02263 --- /dev/null +++ b/lib/services/quote_api_v2.dart @@ -0,0 +1,251 @@ +import "dart:convert"; +import "dart:typed_data"; +import "package:http/http.dart" as http; + + + +class QuoteEngagement { + final int? likes; + final int? retweets; + final int? replies; + final int? views; + + QuoteEngagement({ + this.likes, + this.retweets, + this.replies, + this.views, + }); + + Map toJson() { + return { + if (likes != null) "likes": likes, + if (retweets != null) "retweets": retweets, + if (replies != null) "replies": replies, + if (views != null) "views": views, + }; + } + + factory QuoteEngagement.fromJson(Map json) { + return QuoteEngagement( + likes: json["likes"], + retweets: json["retweets"], + replies: json["replies"], + views: json["views"], + ); + } +} + + +class QuoteSessionRequest { + final String? displayName; + final String? username; + final dynamic avatarUrl; + final String? text; + final dynamic imageUrl; + final int? timestamp; + final bool? verified; + final QuoteEngagement? engagement; + final bool clearAvatarUrl; + final bool clearImageUrl; + final bool clearEngagement; + + QuoteSessionRequest({ + this.displayName, + this.username, + this.avatarUrl, + this.text, + this.imageUrl, + this.timestamp, + this.verified, + this.engagement, + this.clearAvatarUrl = false, + this.clearImageUrl = false, + this.clearEngagement = false, + }); + + + // convert img bytes to base64 data uri + String? _encodeImage(dynamic image) { + if (image == null) return null; + if (image is String) return image; + if (image is Uint8List) { + final base64String = base64Encode(image); + return "data:image/png;base64,$base64String"; + } + return null; + } + + Map toJson() { + final map = {}; + + if (displayName != null) map["displayName"] = displayName; + if (username != null) map["username"] = username; + + if (clearAvatarUrl) { + map["avatarUrl"] = null; + } else { + final encodedAvatar = _encodeImage(avatarUrl); + if (encodedAvatar != null) map["avatarUrl"] = encodedAvatar; + } + + if (text != null) map["text"] = text; + + if (clearImageUrl) { + map["imageUrl"] = null; + } else { + final encodedImage = _encodeImage(imageUrl); + if (encodedImage != null) map["imageUrl"] = encodedImage; + } + + if (timestamp != null) map["timestamp"] = timestamp; + + if (verified != null) map["verified"] = verified; + + if (clearEngagement) { + map["engagement"] = null; + } else if (engagement != null) { + map["engagement"] = engagement!.toJson(); + } + + return map; + } +} + +class QuoteSession { + final String id; + final String? displayName; + final String? username; + final String? avatarUrl; + final String? text; + final String? imageUrl; + final int? timestamp; + final bool? verified; + final QuoteEngagement? engagement; + final int createdAt; + final int updatedAt; + + QuoteSession({ + required this.id, + this.displayName, + this.username, + this.avatarUrl, + this.text, + this.imageUrl, + this.timestamp, + this.verified, + this.engagement, + required this.createdAt, + required this.updatedAt, + }); + + factory QuoteSession.fromJson(Map json) { + return QuoteSession( + id: json["id"], + displayName: json["displayName"], + username: json["username"], + avatarUrl: json["avatarUrl"], + text: json["text"], + imageUrl: json["imageUrl"], + timestamp: json["timestamp"], + verified: json["verified"], + engagement: json["engagement"] != null + ? QuoteEngagement.fromJson(json["engagement"]) + : null, + createdAt: json["createdAt"], + updatedAt: json["updatedAt"], + ); + } +} + + +class QuoteGeneratorApiV2 { + static const String _baseUrl = "https://quotes.imbenji.net"; + // static const String _baseUrl = "http://localhost:3000"; + + + // create new session + static Future createSession(QuoteSessionRequest request) async { + final url = Uri.parse("$_baseUrl/v2/quote"); + + final response = await http.post( + url, + headers: {"Content-Type": "application/json"}, + body: jsonEncode(request.toJson()), + ); + + if (response.statusCode == 201) { + final data = jsonDecode(response.body); + return QuoteSession.fromJson(data); + } else { + throw Exception("Failed to create sesion: ${response.statusCode}"); + } + } + + + // get session state + static Future getSession(String sessionId) async { + final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); + + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return QuoteSession.fromJson(data); + } else { + throw Exception("Failed to get session: ${response.statusCode}"); + } + } + + // update session fields (only send whats changed) + static Future updateSession( + String sessionId, + QuoteSessionRequest updates, + ) async { + final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); + + final response = await http.patch( + url, + headers: {"Content-Type": "application/json"}, + body: jsonEncode(updates.toJson()), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return QuoteSession.fromJson(data); + } else { + throw Exception("Failed to updte session: ${response.statusCode}"); + } + } + + // render the session as image + static Future generateImage(String sessionId) async { + final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image"); + + final response = await http.get(url); + + if (response.statusCode == 200) { + return response.bodyBytes; + } else { + throw Exception("Failed to genrate image: ${response.statusCode}"); + } + } + + + // delete session + static Future deleteSession(String sessionId) async { + final url = Uri.parse("$_baseUrl/v2/quote/$sessionId"); + + final response = await http.delete(url); + + if (response.statusCode != 204) { + throw Exception("Failed to delte session: ${response.statusCode}"); + } + } + + + // helper to get curren timestamp in seconds + static int getCurrentTimestamp() { + return DateTime.now().millisecondsSinceEpoch ~/ 1000; + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..451656f --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + +SPEC CHECKSUMS: + file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b461d37..1927840 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 4B88E3BA5783FAAA20C55FF8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B1D06A2639F3E3C2D980953 /* Pods_RunnerTests.framework */; }; + 66220A6CD753CFE95C5E895B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2275505DFB8F93088ED6B919 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 027FCB389F2CAAB3130196E9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 02934033A4D23A11AC357D9C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1B1D06A2639F3E3C2D980953 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2275505DFB8F93088ED6B919 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* quotegen_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "quotegen_client.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* quotegen_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = quotegen_client.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +82,12 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5348D45CFF6462B1764A19C6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BCF862636D90C513DDDEC9AC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + BF503F87C67D76C2AAECB687 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F88395ACF672EC99D7216FE7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4B88E3BA5783FAAA20C55FF8 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,12 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 66220A6CD753CFE95C5E895B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1B8C4A4125941B1E08A389DC /* Pods */ = { + isa = PBXGroup; + children = ( + 02934033A4D23A11AC357D9C /* Pods-Runner.debug.xcconfig */, + 5348D45CFF6462B1764A19C6 /* Pods-Runner.release.xcconfig */, + BF503F87C67D76C2AAECB687 /* Pods-Runner.profile.xcconfig */, + 027FCB389F2CAAB3130196E9 /* Pods-RunnerTests.debug.xcconfig */, + BCF862636D90C513DDDEC9AC /* Pods-RunnerTests.release.xcconfig */, + F88395ACF672EC99D7216FE7 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 1B8C4A4125941B1E08A389DC /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 2275505DFB8F93088ED6B919 /* Pods_Runner.framework */, + 1B1D06A2639F3E3C2D980953 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 2910AB2B6910A71DCB69BE84 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 0E297FF21CD9AA21EB56B11C /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 4051E39AED5CF4F08B394FE0 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0E297FF21CD9AA21EB56B11C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 2910AB2B6910A71DCB69BE84 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +405,23 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 4051E39AED5CF4F08B394FE0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 027FCB389F2CAAB3130196E9 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BCF862636D90C513DDDEC9AC /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F88395ACF672EC99D7216FE7 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..8165abf 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,9 @@ com.apple.security.network.server + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..741903e 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + diff --git a/pubspec.lock b/pubspec.lock index 628936c..afc38dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" jovial_misc: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 98b3252..f911435 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,11 @@ dependencies: share_plus: ^10.1.4 path_provider: ^2.1.5 shared_preferences: ^2.3.4 + intl: ^0.19.0 + + # Ad integration + google_mobile_ads: ^5.2.0 + universal_html: ^2.2.4 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.