From 8c2212b56362f1d5fc604362f860d7ab0a486e24 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Tue, 25 Aug 2026 11:46:33 +0100 Subject: [PATCH] feat: add gallery feature with database integration and rendering capabilities --- ios/Podfile.lock | 13 + lib/main.dart | 118 +- lib/models/gallery_item.dart | 78 + lib/pages/gallery/page.dart | 238 +++ lib/pages/home/page.dart | 1721 +++++++++-------- lib/providers/backend_status_provider.dart | 42 + lib/providers/form_provider.dart | 254 +++ lib/providers/gallery_provider.dart | 92 + lib/providers/render_provider.dart | 240 +++ lib/providers/session_provider.dart | 113 ++ lib/services/gallery_db.dart | 39 + lib/services/quote_api_v2.dart | 253 ++- lib/widgets/tweet_template_widget.dart | 403 ++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 13 + pubspec.lock | 157 +- pubspec.yaml | 11 +- 17 files changed, 2902 insertions(+), 885 deletions(-) create mode 100644 lib/models/gallery_item.dart create mode 100644 lib/pages/gallery/page.dart create mode 100644 lib/providers/backend_status_provider.dart create mode 100644 lib/providers/form_provider.dart create mode 100644 lib/providers/gallery_provider.dart create mode 100644 lib/providers/render_provider.dart create mode 100644 lib/providers/session_provider.dart create mode 100644 lib/services/gallery_db.dart create mode 100644 lib/widgets/tweet_template_widget.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1618ba9..94297de 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -33,6 +33,8 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - file_saver (0.0.1): + - Flutter - Flutter (1.0.0) - Google-Mobile-Ads-SDK (11.13.0): - GoogleUserMessagingPlatform (>= 1.1) @@ -53,17 +55,22 @@ PODS: - Flutter - FlutterMacOS - SwiftyGif (5.4.5) + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: @@ -78,6 +85,8 @@ SPEC REPOS: EXTERNAL SOURCES: file_picker: :path: ".symlinks/plugins/file_picker/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" Flutter: :path: Flutter google_mobile_ads: @@ -88,6 +97,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" @@ -95,6 +106,7 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407 google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957 @@ -104,6 +116,7 @@ SPEC CHECKSUMS: share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86 webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/main.dart b/lib/main.dart index 95f3e68..67ccb11 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,15 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:quotegen_client/pages/gallery/page.dart'; import 'package:quotegen_client/pages/home/page.dart'; +import 'package:quotegen_client/providers/backend_status_provider.dart'; +import 'package:quotegen_client/providers/form_provider.dart'; +import 'package:quotegen_client/providers/gallery_provider.dart'; +import 'package:quotegen_client/providers/render_provider.dart'; +import 'package:quotegen_client/providers/session_provider.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; @@ -13,6 +21,9 @@ void main() async { await MobileAds.instance.initialize(); } + await Hive.initFlutter(); + await Hive.openLazyBox("gallery"); + runApp(const MyApp()); } @@ -22,24 +33,111 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return ShadcnApp.router( + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => FormProvider()), + ChangeNotifierProvider(create: (_) => SessionProvider()), + ChangeNotifierProvider(create: (_) => RenderProvider()), + ChangeNotifierProvider(create: (_) => GalleryProvider()), + ChangeNotifierProvider(create: (_) => BackendStatusProvider()), + ], + child: ShadcnApp.router( title: 'TweetForge', routerConfig: _router, - scaling: defaultTargetPlatform == TargetPlatform.android ? AdaptiveScaling.only( - sizeScaling: 1, - textScaling: 1.1, - radiusScaling: 1.1 - ) : null, + scaling: { + TargetPlatform.iOS: AdaptiveScaling.only( + sizeScaling: 1.3, + textScaling: 1.2, + radiusScaling: 1.3 + ), + TargetPlatform.android: AdaptiveScaling.only( + sizeScaling: 1.2, + textScaling: 1.02, + radiusScaling: 1.0 + ), + }[defaultTargetPlatform] ?? AdaptiveScaling.mobile, theme: ThemeData( - colorScheme: ColorSchemes.darkBlue, + colorScheme: ColorSchemes.darkGray.indigo, + surfaceOpacity: 1, + ), - ); + )); } } GoRouter _router = GoRouter( initialLocation: "/", routes: [ - HomePage.route + + ShellRoute( + builder: (context, state, child) { + return _AppShell(child: child); + }, + routes: [ + HomePage.route, + GalleryPage.route, + ] + ), + + + ], -); \ No newline at end of file +); + +class _AppShell extends StatelessWidget { + final Widget child; + const _AppShell({required this.child}); + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final index = location.startsWith("/gallery") ? 1 : 0; + + double bottomPadding = MediaQuery.of(context).padding.bottom; + + return Column( + children: [ + Expanded(child: child), + + NavigationBar( + index: index, + onSelected: (i) { + if (i == 0) context.go("/"); + else if (i == 1) context.go("/gallery"); + }, + labelType: NavigationLabelType.all, + children: [ + NavigationItem( + label: Text( + "Editor" + ), + child: Icon( + LucideIcons.house + ), + ), + NavigationItem( + label: Text( + "Gallery" + ), + child: Icon( + LucideIcons.galleryHorizontal + ), + ), + // NavigationItem( + // label: Text( + // "Settings" + // ), + // child: Icon( + // LucideIcons.settings + // ), + // ) + ], + ), + + if (defaultTargetPlatform == TargetPlatform.android) + Gap(bottomPadding) + + ], + ); + } +} diff --git a/lib/models/gallery_item.dart b/lib/models/gallery_item.dart new file mode 100644 index 0000000..d37f908 --- /dev/null +++ b/lib/models/gallery_item.dart @@ -0,0 +1,78 @@ +import "dart:convert"; + +class GalleryItem { + final String id; + final String displayName; + final String handle; + final String content; + final bool verified; + final int timestamp; + final String? avatarImageB64; + final String? postImageB64; + final String replies; + final String retweets; + final String likes; + final String views; + final int createdAt; + final String? title; + + GalleryItem({ + required this.id, + required this.displayName, + required this.handle, + required this.content, + required this.verified, + required this.timestamp, + this.avatarImageB64, + this.postImageB64, + required this.replies, + required this.retweets, + required this.likes, + required this.views, + required this.createdAt, + this.title, + }); + + Map toJson() { + return { + "id": id, + "displayName": displayName, + "handle": handle, + "content": content, + "verified": verified, + "timestamp": timestamp, + "avatarImageB64": avatarImageB64, + "postImageB64": postImageB64, + "replies": replies, + "retweets": retweets, + "likes": likes, + "views": views, + "createdAt": createdAt, + "title": title, + }; + } + + factory GalleryItem.fromJson(Map json) { + return GalleryItem( + id: json["id"] as String, + displayName: json["displayName"] as String? ?? "", + handle: json["handle"] as String? ?? "", + content: json["content"] as String? ?? "", + verified: json["verified"] as bool? ?? false, + timestamp: json["timestamp"] as int? ?? 0, + avatarImageB64: json["avatarImageB64"] as String?, + postImageB64: json["postImageB64"] as String?, + replies: json["replies"] as String? ?? "", + retweets: json["retweets"] as String? ?? "", + likes: json["likes"] as String? ?? "", + views: json["views"] as String? ?? "", + createdAt: json["createdAt"] as int? ?? 0, + title: json["title"] as String?, + ); + } + + String toJsonString() => jsonEncode(toJson()); + + factory GalleryItem.fromJsonString(String s) => + GalleryItem.fromJson(jsonDecode(s) as Map); +} diff --git a/lib/pages/gallery/page.dart b/lib/pages/gallery/page.dart new file mode 100644 index 0000000..5d7ed95 --- /dev/null +++ b/lib/pages/gallery/page.dart @@ -0,0 +1,238 @@ +import "dart:typed_data"; + +import "package:flutter/foundation.dart"; +import "package:go_router/go_router.dart"; +import "package:intl/intl.dart"; +import "package:provider/provider.dart"; +import "package:quotegen_client/models/gallery_item.dart"; +import "package:quotegen_client/providers/form_provider.dart"; +import "package:quotegen_client/providers/gallery_provider.dart"; +import "package:quotegen_client/providers/session_provider.dart"; +import "package:quotegen_client/widgets/tweet_template_widget.dart"; +import "package:shadcn_flutter/shadcn_flutter.dart"; + + +class GalleryPage extends StatelessWidget { + + static GoRoute route = GoRoute( + path: "/gallery", + builder: (context, state) => GalleryPage(), + ); + + @override + Widget build(BuildContext context) { + if (kIsWeb) { + return Scaffold( + child: Center(child: Text("Gallery not available on web")), + ); + } + + final gallery = context.watch(); + final double topPadding = MediaQuery.of(context).padding.top; + + if (gallery.isLoading) { + return Scaffold( + child: Center(child: CircularProgressIndicator()), + ); + } + + if (gallery.items.isEmpty) { + return Scaffold( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.galleryHorizontal, + size: 48, + ), + Gap(12), + Text("No saved posts yet").large.semiBold, + Gap(4), + Text("Use the bookmark button in the editor to save a post").small.muted, + ], + ), + ), + ); + } + + return Scaffold( + child: CustomScrollView( + slivers: [ + + SliverToBoxAdapter( + child: Gap(topPadding + 14), + ), + + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 14), + sliver: SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.75, + ), + itemCount: gallery.items.length, + itemBuilder: (context, index) { + final item = gallery.items[index]; + return _GalleryCard( + item: item, + avatarBytes: gallery.decodeAvatar(item), + postImageBytes: gallery.decodePostImage(item), + ); + }, + ), + ), + + SliverToBoxAdapter( + child: Gap(120), + ), + + ], + ), + ); + } +} + + +class _GalleryCard extends StatelessWidget { + + final GalleryItem item; + final Uint8List? avatarBytes; + final Uint8List? postImageBytes; + + const _GalleryCard({ + required this.item, + this.avatarBytes, + this.postImageBytes, + }); + + @override + Widget build(BuildContext context) { + final ts = DateTime.fromMillisecondsSinceEpoch(item.createdAt); + final formatted = DateFormat("MMM d, yyyy").format(ts); + + final engagement = (item.replies == "" && item.retweets == "" && item.likes == "" && item.views == "") + ? null + : { + "replies": int.tryParse(item.replies.replaceAll(",", "")) ?? 0, + "retweets": int.tryParse(item.retweets.replaceAll(",", "")) ?? 0, + "likes": int.tryParse(item.likes.replaceAll(",", "")) ?? 0, + "views": int.tryParse(item.views.replaceAll(",", "")) ?? 0, + }; + + + return GestureDetector( + onTap: () => _onTap(context), + child: OutlinedContainer( + clipBehavior: Clip.hardEdge, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + + Expanded( + child: IgnorePointer( + child: TweetTemplateWidget( + displayName: item.displayName, + username: item.handle, + isVerified: item.verified, + text: item.content, + timestamp: DateTime.fromMillisecondsSinceEpoch(item.timestamp * 1000), + avatarBytes: avatarBytes, + imageBytes: postImageBytes, + engagement: engagement, + ), + ), + ), + + Divider(), + + Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.displayName.isEmpty ? "Untitled" : item.displayName).small.semiBold, + Text(formatted).xSmall.muted, + ], + ), + ), + + IconButton.destructive( + icon: Icon(LucideIcons.trash2, size: 14), + onPressed: () { + context.read().deleteItem(item.id); + }, + ), + ], + ), + ), + + ], + ), + ), + ); + } + + Future _onTap(BuildContext context) async { + final form = context.read(); + final session = context.read(); + final gallery = context.read(); + + bool hasContent = form.displayName.isNotEmpty || + form.handle.isNotEmpty || + form.content.isNotEmpty || + form.avatarImage != null || + form.postImage != null; + + if (hasContent) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text("Load this post?"), + content: Text("Your current editor content will be replaced."), + actions: [ + Button.outline( + child: Text("Cancel"), + onPressed: () => Navigator.of(ctx).pop(false), + ), + Button.primary( + child: Text("Load"), + onPressed: () => Navigator.of(ctx).pop(true), + ), + ], + ), + ); + + if (confirmed != true) return; + } + + // load item fields into form + form.setDisplayName(item.displayName); + form.setHandle(item.handle); + form.setContent(item.content); + form.setVerified(item.verified); + + final dt = DateTime.fromMillisecondsSinceEpoch(item.timestamp * 1000); + form.setDate(DateTime(dt.year, dt.month, dt.day)); + form.setTime(TimeOfDay(hour: dt.hour, minute: dt.minute)); + + form.setAvatarImage(gallery.decodeAvatar(item)); + form.setPostImage(gallery.decodePostImage(item)); + + form.setReplies(item.replies); + form.setRetweets(item.retweets); + form.setLikes(item.likes); + form.setViews(item.views); + + await session.initializeSession(form.buildFullRequest()); + + if (context.mounted) { + context.go("/"); + } + } +} diff --git a/lib/pages/home/page.dart b/lib/pages/home/page.dart index eb7cc49..7017263 100644 --- a/lib/pages/home/page.dart +++ b/lib/pages/home/page.dart @@ -1,6 +1,5 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; @@ -8,18 +7,24 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; + import 'package:go_router/go_router.dart'; import 'package:image/image.dart' as img; -import 'package:quotegen_client/services/quote_api_v2.dart'; +import 'package:provider/provider.dart'; +import 'package:quotegen_client/providers/backend_status_provider.dart'; +import 'package:quotegen_client/providers/form_provider.dart'; +import 'package:quotegen_client/providers/gallery_provider.dart'; +import 'package:quotegen_client/providers/render_provider.dart'; +import 'package:quotegen_client/providers/session_provider.dart'; import 'package:quotegen_client/widgets/ad_banner.dart'; +import 'package:quotegen_client/widgets/tweet_template_widget.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:intl/intl.dart'; import 'package:file_saver/file_saver.dart'; -// import 'package:http/http.dart' as http; +import 'package:video_player/video_player.dart'; // number formatter for engagement fields class NumberTextInputFormatter extends TextInputFormatter { @@ -65,29 +70,13 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { - bool _isLoading = true; - - Map formData = { - "display_name": "", - "handle": "", - "content": "", - "date": DateTime.now(), - "time": TimeOfDay.now(), - "avatar_image": null, - "post_image": null, - "replies": "", - "retweets": "", - "likes": "", - "views": "", - }; - late TextEditingController _repliesController; late TextEditingController _retweetsController; late TextEditingController _likesController; late TextEditingController _viewsController; - - Uint8List? postPreviewImage; + // key on the live tweet card so we can rasterize it for image export + final GlobalKey _previewCaptureKey = GlobalKey(); @override @@ -97,7 +86,24 @@ class _HomePageState extends State { _retweetsController = TextEditingController(); _likesController = TextEditingController(); _viewsController = TextEditingController(); - _loadFormData(); + + // do this after the first frame so providers are available + WidgetsBinding.instance.addPostFrameCallback((_) async { + final form = context.read(); + final session = context.read(); + + await form.loadFormData(); + + // sync controller text after loading + _repliesController.text = form.replies; + _retweetsController.text = form.retweets; + _likesController.text = form.likes; + _viewsController.text = form.views; + + await session.initializeSession(form.buildFullRequest()); + + session.setLoaded(); + }); } @override @@ -109,16 +115,6 @@ class _HomePageState extends State { 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; } @@ -126,8 +122,9 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + final session = context.watch(); - if (_isLoading) { + if (session.isLoading) { return Scaffold( child: Center( child: CircularProgressIndicator(), @@ -138,7 +135,6 @@ class _HomePageState extends State { double topPadding = MediaQuery.of(context).padding.top; double bottomPadding = MediaQuery.of(context).padding.bottom; - // Check if we're on mobile (iOS/Android) bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); if (isWideLayout) { @@ -149,7 +145,9 @@ class _HomePageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ + SizedBox( + width: 600, height: 600, child: _buildPreview(context) ), @@ -158,7 +156,7 @@ class _HomePageState extends State { ConstrainedBox( constraints: BoxConstraints( - maxWidth: 400, + maxWidth: 600, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -171,47 +169,55 @@ class _HomePageState extends State { ), ], ), + ).withPadding( + horizontal: 14 ), ), ); } - // Mobile layout content Widget mainContent = SingleChildScrollView( - child: Column( - children: [ + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Column( + children: [ - Gap(max(topPadding, 14)), + Gap(max(topPadding + 14, 14)), - _buildPreview(context), + _buildPreview(context), - Gap(14), + Gap(8), - _buildForm(context), + Divider().withPadding( + horizontal: 8 + ), - Gap(14), + Gap(8), - // Add bottom padding on mobile to avoid content being hidden behind ad - if (isMobilePlatform) - Gap(100), // Space for the overlay ad (80px min height + padding) + _buildForm(context), - // Only show inline ad on non-mobile platforms - if (!isMobilePlatform) - AdBanner(), + Gap(14), - Gap(max(bottomPadding, 14)), - ], + if (isMobilePlatform) + Gap(100), + + if (!isMobilePlatform) + AdBanner(), + + Gap(max(bottomPadding, 14)), + ], + ), + ), ), ); - // Wrap in Stack with overlay ad for mobile platforms if (isMobilePlatform) { return Scaffold( child: Stack( children: [ mainContent, - // Bottom overlay ad for mobile Positioned( left: 0, right: 0, @@ -229,309 +235,524 @@ class _HomePageState extends State { } 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( + final form = context.watch(); + final session = context.watch(); + final render = context.watch(); + + bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; + bool isVideoInForm = form.postImage != null && _isVideoBytes(form.postImage!); + + bool isRendering = render.renderStatus == "rendering" || render.renderStatus == "pending"; + bool renderDone = render.renderStatus == "completed" && render.postPreviewImage != null && !isVideo; + bool videoDone = render.renderStatus == "completed" && isVideo + && render.videoController != null && render.videoController!.value.isInitialized; + bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + Widget previewChild; + if (renderDone) { + previewChild = Image.memory( + render.postPreviewImage!, + fit: BoxFit.contain, + ); + } else if (videoDone) { + previewChild = Center( + child: AspectRatio( + aspectRatio: render.videoController!.value.aspectRatio, + child: VideoPlayer(render.videoController!), + ), + ); + } else { + previewChild = RepaintBoundary( + key: _previewCaptureKey, + child: TweetTemplateWidget( + displayName: form.displayName, + username: form.handle, + isVerified: form.verified, + text: form.content, + timestamp: DateTime( + form.date.year, + form.date.month, + form.date.day, + form.time.hour, + form.time.minute, + ), + avatarBytes: form.avatarImage, + imageBytes: !isVideoInForm ? form.postImage : null, + videoBytes: isVideoInForm ? form.postImage : null, + engagement: form.replies == "" && form.retweets == "" && form.likes == "" && form.views == "" ? null : { + "replies": int.tryParse(form.replies.replaceAll(",", "")) ?? 0, + "retweets": int.tryParse(form.retweets.replaceAll(",", "")) ?? 0, + "likes": int.tryParse(form.likes.replaceAll(",", "")) ?? 0, + "views": int.tryParse(form.views.replaceAll(",", "")) ?? 0, + }, + ), + ); + } + + return Column( + children: [ + + AspectRatio( + aspectRatio: 1, + child: OutlinedContainer( + backgroundColor: Colors.black, + child: previewChild, + ), + ).withPadding( + horizontal: 14, + ), + + Gap(4), + + Text( + "Videos are WIP. They may be slightly buggy." + ).muted.textSmall, + + Gap(4), + + if (isVideoInForm) ...[ + + _videoControlPanel(context), + + ] else if (renderDone) ...[ + + Row( children: [ - Image.memory( - postPreviewImage!, - fit: BoxFit.cover, + IconButton.secondary( + icon: Icon(LucideIcons.save), + onPressed: () async { + final saved = await context.read().saveCurrentForm(context.read()); + if (!context.mounted) return; + showToast( + context: context, + builder: (ctx, overlay) => SurfaceCard( + child: Basic( + leading: Icon(saved ? LucideIcons.saveAll : LucideIcons.ban), + title: Text(saved ? "Saved to gallery" : "Already saved"), + ), + ), + ); + }, ), - Align( - alignment: Alignment.topRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if ([TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.macOS].contains(defaultTargetPlatform))...[ - IconButton.secondary( - icon: Icon( - Icons.share, - ), - onPressed: () async { - await shareImage(); - } - ), + Gap(8), - Gap(8), - ], + if (!kIsWeb) ...[ + Expanded( + child: Button.primary( + child: Text("Share"), + leading: Icon(LucideIcons.share2), + alignment: Alignment.center, + onPressed: shareMedia, + ), + ), - IconButton.secondary( - icon: Icon( - LucideIcons.download, - ), - onPressed: () async { - if (postPreviewImage == null) { - print("No image avalable to download"); - return; - } + Gap(8), + ], - try { - // genrate filename with timestmp - final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}"; + if (!isMobilePlatform) ...[ + Expanded( + child: Button.primary( + child: Text("Download"), + leading: Icon(LucideIcons.download), + alignment: Alignment.center, + onPressed: downloadMedia, + ), + ), - // On desktop platforms (macOS, Windows, Linux), use FilePicker to let user choose location - if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) { - String? outputPath = await FilePicker.platform.saveFile( - dialogTitle: "Save Quote Image", - fileName: "$fileName.png", - type: FileType.image, - ); + Gap(8), + ], - if (outputPath != null) { - final file = File(outputPath); - await file.writeAsBytes(postPreviewImage!); - print("Image saved succesfully to: $outputPath"); - } - } else { - // For mobile (iOS/Android) and web, use FileSaver - await FileSaver.instance.saveFile( - name: fileName, - bytes: postPreviewImage!, - ext: "png", - mimeType: MimeType.png, - ); - print("Image downloaded succesfully"); - } - } catch (e) { - print("Error downloadin image: $e"); - } - } - ), - - Gap(8), - - IconButton.outline( - icon: Icon( - LucideIcons.link - ), - onPressed: () async { - if (_currentSessionId == null) { - print("No session available"); - return; - } - - try { - // create snapshot link - QuoteSnapshot snapshot = await QuoteGeneratorApiV2.createSnapshotLink(_currentSessionId!); - - // copy URL to clipboard - await Clipboard.setData(ClipboardData(text: snapshot.url)); - - if (!mounted) return; - - // show toast notification - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - title: const Text('Snapshot Link Copied'), - subtitle: const Text('The snapshot link has been copied to clipboard'), - trailingAlignment: Alignment.center, - ), - ); - } - ); - - print("Snapshot link created: ${snapshot.url}"); - } catch (e) { - print("Error creating snapshot link: $e"); - - if (!mounted) return; - - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - title: const Text('Error'), - subtitle: const Text('Failed to create snapshot link'), - trailingAlignment: Alignment.center, - ), - ); - } - ); - } - }, - ) - - ].reversed.toList(), - ) - ).withPadding( - all: 12 - ) + IconButton.secondary( + icon: Icon(LucideIcons.refreshCw), + onPressed: () { + final s = context.read(); + final r = context.read(); + final f = context.read(); + bool isVid = f.postImage != null && _isVideoBytes(f.postImage!); + r.renderOutput(s.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); + }, + ), ], + ).withPadding(horizontal: 14), + + ] else ...[ + + Row( + children: [ + IconButton.secondary( + icon: Icon(LucideIcons.save), + onPressed: () async { + final saved = await context.read().saveCurrentForm(context.read()); + if (!context.mounted) return; + showToast( + context: context, + builder: (ctx, overlay) => SurfaceCard( + child: Basic( + leading: Icon(saved ? LucideIcons.saveAll : LucideIcons.ban), + title: Text(saved ? "Saved to gallery" : "Already saved"), + ), + ), + ); + }, + ), + + Gap(8), + + Expanded( + child: Button.primary( + child: Text( + isRendering ? "Rendering..." : "Render Image" + ), + leading: Icon(LucideIcons.image), + alignment: Alignment.center, + enabled: !isRendering && session.canRender, + onPressed: () { + final s = context.read(); + final r = context.read(); + r.renderOutput(s.currentSessionId, false, previewKey: _previewCaptureKey); + }, + ), + ), + ], + ).withPadding( + horizontal: 14, ), - ) - ).withPadding( - horizontal: 14 + + Gap(8), + + SizedBox( + height: 4, + width: double.infinity, + child: LinearProgressIndicator( + value: isRendering ? null : 0, + ), + ).withPadding( + horizontal: 14, + ), + + ], + ], + ); + } + + Widget _videoControlPanel(BuildContext context) { + final session = context.watch(); + final render = context.watch(); + final form = context.watch(); + final backend = context.watch(); + + bool isRendering = render.renderStatus == "rendering" || render.renderStatus == "pending"; + bool videoDone = render.renderStatus == "completed" && render.postPreviewImage != null + && (render.postPreviewContentType?.startsWith("video/") ?? false); + bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + // video rendering goes through the server, so if its down theres no point showing the button + bool serverDown = backend.hasChecked && !backend.isOnline; + + String stageLabel; + if (render.renderStage == "encoding") { + stageLabel = "Encoding... ${render.renderProgress}%"; + } else if (isRendering) { + stageLabel = "Rendering... ${render.renderProgress}%"; + } else { + stageLabel = "Render Video"; + } + + return Column( + children: [ + + if (videoDone) ...[ + + Row( + children: [ + if (!kIsWeb) ...[ + Expanded( + child: Button.primary( + child: Text("Share"), + leading: Icon(LucideIcons.share2), + alignment: Alignment.center, + onPressed: shareMedia, + ), + ), + + Gap(8), + ], + + if (!isMobilePlatform) ...[ + Expanded( + child: Button.primary( + child: Text("Download"), + leading: Icon(LucideIcons.download), + alignment: Alignment.center, + onPressed: downloadMedia, + ), + ), + + Gap(8), + ], + + if (!serverDown) + IconButton.secondary( + icon: Icon(LucideIcons.refreshCw), + onPressed: () { + bool isVid = form.postImage != null && _isVideoBytes(form.postImage!); + render.renderOutput(session.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); + }, + ), + ], + ).withPadding(horizontal: 14), + + ] else if (serverDown) ...[ + + Row( + children: [ + Expanded( + child: SurfaceCard( + child: Basic( + leading: Icon(LucideIcons.cloudOff), + title: Text("Video rendering unavailable"), + subtitle: Text("Cant reach the server right now, try again in a bit."), + ), + ), + ), + ], + ).withPadding(horizontal: 14), + + ] else ...[ + + Row( + children: [ + Expanded( + child: Button.primary( + enabled: !isRendering && session.canRender, + alignment: Alignment.center, + onPressed: () { + bool isVid = form.postImage != null && _isVideoBytes(form.postImage!); + render.renderOutput(session.currentSessionId, isVid, previewKey: isVid ? null : _previewCaptureKey); + }, + leading: Icon(LucideIcons.boxes), + child: Text(stageLabel), + ), + ), + ], + ).withPadding(horizontal: 14), + + ], + + Gap(8), + + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SizedBox( + height: 4, + child: LinearProgressIndicator( + value: videoDone ? 1.0 : (isRendering ? render.renderProgress / 100 : 0), + color: videoDone ? Colors.green : null, + ), + ), + ), + ], + ).withPadding(horizontal: 14), + + ] ); } Widget _buildForm(BuildContext context) { + final form = context.watch(); + final session = context.watch(); + final render = context.watch(); + return Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - height: isWideLayout ? 72 : 90, + IntrinsicHeight( child: Row( - children: [ - Expanded( - child: ButtonGroup.vertical( - children: [ + children: [ + Expanded( + child: ButtonGroup.vertical( + children: [ - ButtonGroup.horizontal( - children: [ - Expanded( - child: TextField( - placeholder: Text( - "Display Name" + ButtonGroup.horizontal( + children: [ + Expanded( + child: TextField( + placeholder: Text( + "Display Name" + ), + features: [ + InputFeature.leading( + Icon( + LucideIcons.user + ), + ) + ], + initialValue: form.displayName, + onChanged: (value) { + form.setDisplayName(value); + form.saveFormData(); + session.onSubmit( + changedField: "display_name", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, ), - features: [ - InputFeature.leading( - Icon( - LucideIcons.user - ), - ) - ], - initialValue: formData["display_name"], - onChanged: (value) { - setState(() { - formData["display_name"] = value; - }); - _saveFormData(); - onSubmit(changedField: "display_name"); - }, ), - ), - VerticalDivider(), + VerticalDivider(), - SizedBox( - height: 10, - child: Toggle( - value: formData["verified"] ?? false, - child: Icon( - LucideIcons.badgeCheck, + SizedBox( + height: 10, + child: IconButton.secondary( + icon: Icon( + LucideIcons.badgeCheck, + color: form.verified + ? Colors.blue.shade400 + : Colors.gray.shade600, + ), + onPressed: () { + form.setVerified(!form.verified); + form.saveFormData(); + session.onSubmit( + changedField: "verified", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, ), - style: ButtonStyle.primaryIcon(), - onChanged: (value) { - setState(() { - formData["verified"] = value; - }); - _saveFormData(); - onSubmit(changedField: "verified"); - }, - ), - ) - ], - ), - - TextField( - placeholder: Text( - "Handle" - ), - features: [ - InputFeature.leading( - Icon( - LucideIcons.atSign ) - ) - ], - initialValue: formData["handle"], - onChanged: (value) { - setState(() { - formData["handle"] = value; - }); - _saveFormData(); - onSubmit(changedField: "handle"); - }, - ) - - ], - ) - ), - - Gap(14), - - AspectRatio( - aspectRatio: 1, - child: formData["avatar_image"] == null ? Button.card( - onPressed: () async { - Uint8List? imageBytes = await pickImageFile(); - - if (imageBytes != null) { - // downscale avatar image - Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400); - - if (!mounted) return; - setState(() { - formData["avatar_image"] = resizedBytes ?? imageBytes; - }); - _saveFormData(); - onSubmit(changedField: "avatar_image"); - } - }, - child: FittedBox( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - LucideIcons.plus + ], ), - Gap(4), - Text( - "Avatar\nImage", - textAlign: TextAlign.center, - ).xSmall.semiBold - ], - ), - ), - ) : GestureDetector( - onTap: () { - if (!mounted) return; - setState(() { - formData["avatar_image"] = null; - }); - _saveFormData(); - onSubmit(changedField: "avatar_image"); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - children: [ - Image.memory( - formData["avatar_image"], - fit: BoxFit.cover, - ), - Center( - child: Icon( - LucideIcons.trash2, - shadows: [ - Shadow( - color: Colors.black.withOpacity(0.9), - blurRadius: 6, + + TextField( + placeholder: Text( + "Handle" + ), + features: [ + InputFeature.leading( + Icon( + LucideIcons.atSign ) + ) + ], + initialValue: form.handle, + onChanged: (value) { + form.setHandle(value); + form.saveFormData(); + session.onSubmit( + changedField: "handle", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, + ) + + ], + ) + ), + + Gap(8), + + if (form.avatarImage == null) + AspectRatio( + aspectRatio: 1, + child: Button.card( + onPressed: () async { + Uint8List? imageBytes = await pickImageFile(); + + if (imageBytes != null) { + Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400); + + if (!mounted) return; + form.setAvatarImage(resizedBytes ?? imageBytes); + form.saveFormData(); + session.onSubmit( + changedField: "avatar_image", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + } + }, + child: Column( + children: [ + Row( + children: [ + Icon( + LucideIcons.plus + ), + Gap(2), + Icon( + LucideIcons.user, + ), ], ), - ) - ], + + Gap(4), + + Text( + "Avatar", + textAlign: TextAlign.center, + ).xSmall.semiBold + ], + ), ), - ), - ), - ) - ], - ), + ) + else + AspectRatio( + aspectRatio: 1, + child: OutlinedContainer( + child: ClipRRect( + borderRadius: Theme.of(context).borderRadiusLg, + child: GestureDetector( + onTap: () { + form.setAvatarImage(null); + form.saveFormData(); + session.onSubmit( + changedField: "avatar_image", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, + child: Stack( + children: [ + Positioned.fill( + child: FittedBox( + fit: BoxFit.cover, + child: Image.memory( + height: 1, + width: 1, + form.avatarImage!, + fit: BoxFit.cover, + ), + ), + ), + + Center( + child: Icon( + LucideIcons.trash2, + color: Colors.white.withOpacity(0.9), + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.9), + blurRadius: 6, + ) + ] + ), + ) + ], + ) + ), + ), + ), + ) + + ], + ), ).withPadding( horizontal: 14 ), @@ -543,69 +764,100 @@ class _HomePageState extends State { child: ButtonGroup.vertical( children: [ - TextArea( - placeholder: Text( - "Content" + SizedBox( + height: 100, + child: TextField( + placeholder: Text( + "Content" + ), + initialValue: form.content, + onChanged: (value) { + form.setContent(value); + form.saveFormData(); + session.onSubmit( + changedField: "content", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, + expands: false, + maxLines: 100, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + ), - initialValue: formData["content"], - onChanged: (value) { - setState(() { - formData["content"] = value; - }); - _saveFormData(); - onSubmit(changedField: "content"); - }, - expands: false, - expandableHeight: false, - expandableWidth: false, - textAlignVertical: TextAlignVertical.top, ), ButtonGroup.horizontal( children: [ Expanded( child: DatePicker( - value: formData["date"], + value: form.date, onChanged: (DateTime? date) { if (date != null) { - setState(() { - formData["date"] = date; - }); - _saveFormData(); - onSubmit(changedField: "datetime"); + form.setDate(date); + form.setSavedTimestamp(null); + form.saveFormData(); + session.onSubmit( + changedField: "datetime", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); } }, ), ), TimePicker( - value: formData["time"], + value: form.time, onChanged: (TimeOfDay? time) { if (time != null) { - setState(() { - formData["time"] = time; - }); - _saveFormData(); - onSubmit(changedField: "datetime"); + form.setTime(time); + form.setSavedTimestamp(null); + form.saveFormData(); + session.onSubmit( + changedField: "datetime", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); } }, ), + if (form.savedTimestamp == null) Button.secondary( child: Text( "Now" ), onPressed: () { - DateTime now = DateTime.now(); - TimeOfDay nowTime = TimeOfDay.now(); - setState(() { - formData["date"] = now; - formData["time"] = nowTime; - }); - _saveFormData(); - onSubmit(changedField: "datetime"); + form.setDate(DateTime.now()); + form.setTime(TimeOfDay.now()); + form.saveFormData(); + session.onSubmit( + changedField: "datetime", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, - ) + ), + + if (form.savedTimestamp != null) + Button.primary( + child: Text( + "Undo" + ), + onPressed: () { + DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(form.savedTimestamp!); + form.setDate(DateTime(dateTime.year, dateTime.month, dateTime.day)); + form.setTime(TimeOfDay(hour: dateTime.hour, minute: dateTime.minute)); + form.setSavedTimestamp(null); + session.onSubmit( + changedField: "datetime", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, + ) ] ), @@ -623,87 +875,110 @@ class _HomePageState extends State { minWidth: double.infinity, minHeight: 150, ), - child: formData["post_image"] == null ? CardButton( + child: form.postImage == null ? CardButton( child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - LucideIcons.plus - ), - Text( - "Add Image" - ).xSmall.semiBold + Icon(LucideIcons.plus), + Text("Add Media").xSmall.semiBold ], ), onPressed: () async { - Uint8List? imageBytes = await pickImageFile(); - - if (imageBytes != null) { + Uint8List? mediaBytes = await pickMediaFile(); + if (mediaBytes != null) { if (!mounted) return; - setState(() { - formData["post_image"] = imageBytes; - }); - _saveFormData(); - onSubmit(changedField: "post_image"); + form.setPostImage(mediaBytes); + form.saveFormData(); + session.onSubmit( + changedField: "post_image", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); } }, - ) : 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: [ + ) : OutlinedContainer( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + 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"); - } - }, + if (_isVideoBytes(form.postImage!)) ...[ + AspectRatio( + aspectRatio: 16 / 9, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.video, + size: 48, + ), + Gap(8), + Text("Video uploaded").small.semiBold, + ], ), - - Gap(8), - - IconButton.destructive( - icon: Icon( - LucideIcons.trash2, - ), - onPressed: () { - if (!mounted) return; - setState(() { - formData["post_image"] = null; - }); - _saveFormData(); - onSubmit(changedField: "post_image"); - }, - ), - - ], + ), + ) + ] else ...[ + Image.memory( + form.postImage!, + fit: BoxFit.cover, ), - ).withPadding( - all: 4 - ), - ) - ], + ], + + 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; + form.setPostImage(imageBytes); + form.saveFormData(); + session.onSubmit( + changedField: "post_image", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + } + }, + ), + + Gap(8), + + IconButton.destructive( + icon: Icon( + LucideIcons.trash2, + ), + onPressed: () { + form.setPostImage(null); + form.saveFormData(); + session.onSubmit( + changedField: "post_image", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); + }, + ), + + ], + ), + ).withPadding( + all: 6 + ), + ) + ], + ), ), ), ).withPadding( @@ -738,12 +1013,15 @@ class _HomePageState extends State { ], controller: _repliesController, onChanged: (value) { - setState(() { - formData["replies"] = value; - _fillEmptyEngagement(); - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + form.setReplies(value); + form.fillEmptyEngagement(); + _syncEngagementControllers(form); + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, ), ), @@ -763,12 +1041,15 @@ class _HomePageState extends State { ], controller: _retweetsController, onChanged: (value) { - setState(() { - formData["retweets"] = value; - _fillEmptyEngagement(); - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + form.setRetweets(value); + form.fillEmptyEngagement(); + _syncEngagementControllers(form); + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, ), ), @@ -792,12 +1073,15 @@ class _HomePageState extends State { ], controller: _likesController, onChanged: (value) { - setState(() { - formData["likes"] = value; - _fillEmptyEngagement(); - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + form.setLikes(value); + form.fillEmptyEngagement(); + _syncEngagementControllers(form); + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, ), ), @@ -817,12 +1101,15 @@ class _HomePageState extends State { ], controller: _viewsController, onChanged: (value) { - setState(() { - formData["views"] = value; - _fillEmptyEngagement(); - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + form.setViews(value); + form.fillEmptyEngagement(); + _syncEngagementControllers(form); + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, ), ), @@ -835,7 +1122,6 @@ class _HomePageState extends State { Button.primary( onPressed: () { - // randomize engagement metrics Random random = Random(); final formatter = NumberFormat("#,###"); final replies = formatter.format(random.nextInt(10000)); @@ -843,19 +1129,22 @@ class _HomePageState extends State { 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; + form.setReplies(replies); + form.setRetweets(retweets); + form.setLikes(likes); + form.setViews(views); - _repliesController.text = replies; - _retweetsController.text = retweets; - _likesController.text = likes; - _viewsController.text = views; - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + _repliesController.text = replies; + _retweetsController.text = retweets; + _likesController.text = likes; + _viewsController.text = views; + + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, child: Icon( LucideIcons.dices @@ -864,19 +1153,22 @@ class _HomePageState extends State { Button.destructive( onPressed: () { - setState(() { - formData["replies"] = ""; - formData["retweets"] = ""; - formData["likes"] = ""; - formData["views"] = ""; + form.setReplies(""); + form.setRetweets(""); + form.setLikes(""); + form.setViews(""); - _repliesController.clear(); - _retweetsController.clear(); - _likesController.clear(); - _viewsController.clear(); - }); - _saveFormData(); - onSubmit(changedField: "engagement"); + _repliesController.clear(); + _retweetsController.clear(); + _likesController.clear(); + _viewsController.clear(); + + form.saveFormData(); + session.onSubmit( + changedField: "engagement", + request: form.buildFullRequest(), + onDebounceSettled: render.clearRender, + ); }, child: Icon( LucideIcons.trash2 @@ -892,6 +1184,14 @@ class _HomePageState extends State { ); } + // sync controller text for fields that fillEmptyEngagement may have auto-filled + void _syncEngagementControllers(FormProvider form) { + if (_repliesController.text != form.replies) _repliesController.text = form.replies; + if (_retweetsController.text != form.retweets) _retweetsController.text = form.retweets; + if (_likesController.text != form.likes) _likesController.text = form.likes; + if (_viewsController.text != form.views) _viewsController.text = form.views; + } + // pick image from file system Future pickImageFile() async { FilePickerResult? result = await FilePicker.platform.pickFiles( @@ -918,6 +1218,69 @@ class _HomePageState extends State { return null; } + // pick video from file system + Future pickVideoFile() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.video, + allowMultiple: false, + ); + + if (result != null) { + Uint8List? videoBytes; + + // on web, bytes is availble directly + if (result.files.single.bytes != null) { + videoBytes = 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!); + videoBytes = await file.readAsBytes(); + } + + return videoBytes; + } + + return null; + } + + // pick image or video from file system + Future pickMediaFile() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.media, + allowMultiple: false, + ); + + if (result != null) { + if (result.files.single.bytes != null) { + return result.files.single.bytes!; + } else if (result.files.single.path != null) { + File file = File(result.files.single.path!); + return await file.readAsBytes(); + } + } + + return null; + } + + // check if bytes are a video based on magic bytes + bool _isVideoBytes(Uint8List bytes) { + if (bytes.length < 12) return false; + + // MP4: starts with ftyp box + if (bytes[4] == 0x66 && bytes[5] == 0x74 && + bytes[6] == 0x79 && bytes[7] == 0x70) { + return true; + } + // WebM: starts with 0x1A45DFA3 + if (bytes[0] == 0x1A && bytes[1] == 0x45 && + bytes[2] == 0xDF && bytes[3] == 0xA3) { + return true; + } + + return false; + } + // resize image to reduce payload size without comression Future compressImage(Uint8List bytes, {int maxWidth = 800, int quality = 85}) async { try { @@ -937,400 +1300,64 @@ class _HomePageState extends State { } } - int _submitCount = 0; - String? _currentSessionId; + Future shareMedia() async { + final render = context.read(); - // combine date and time into unix timestamp (in seconds for API) - int _combineDateTime() { - DateTime date = formData["date"]; - TimeOfDay time = formData["time"]; - - DateTime combined = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - - return combined.millisecondsSinceEpoch ~/ 1000; - } - - void onSubmit({String? changedField}) async { - - int currentSubmit = ++_submitCount; - - await Future.delayed(Duration(milliseconds: 700)); - - // Only proceed if this is the latest submit - if (currentSubmit != _submitCount) return; - - if (!mounted) return; - setState(() { - postPreviewImage = null; - }); - - print("Form Data Submitted: $formData"); - - // Wait for session to be ready - while (_currentSessionId == null) { - await Future.delayed(Duration(milliseconds: 100)); - } - - // Build request with only changed field - QuoteSessionRequest request; - - if (changedField == null) { - // Initial load - send everything - QuoteEngagement? engagement; - if (formData["likes"] != "" || formData["retweets"] != "" || - formData["replies"] != "" || formData["views"] != "") { - engagement = QuoteEngagement( - likes: _parseNum(formData["likes"]), - retweets: _parseNum(formData["retweets"]), - replies: _parseNum(formData["replies"]), - views: _parseNum(formData["views"]), - ); - } - - request = QuoteSessionRequest( - displayName: formData["display_name"], - username: formData["handle"].toLowerCase(), - text: formData["content"], - timestamp: _combineDateTime(), - avatarUrl: formData["avatar_image"], - imageUrl: formData["post_image"], - verified: formData["verified"] ?? false, - engagement: engagement, - ); - } else { - // Only send the changed field - final engagement = (changedField == "engagement" || changedField?.startsWith("engagement_") == true) ? _buildEngagement(changedField!) : null; - final shouldClearEngagement = changedField == "engagement" && engagement == null; - - request = QuoteSessionRequest( - displayName: changedField == "display_name" ? formData["display_name"] : null, - username: changedField == "handle" ? formData["handle"].toLowerCase() : null, - text: changedField == "content" ? formData["content"] : null, - timestamp: changedField == "datetime" ? _combineDateTime() : null, - avatarUrl: changedField == "avatar_image" ? formData["avatar_image"] : null, - imageUrl: changedField == "post_image" ? formData["post_image"] : null, - verified: changedField == "verified" ? (formData["verified"] ?? false) : null, - engagement: engagement, - clearAvatarUrl: changedField == "avatar_image" && formData["avatar_image"] == null, - clearImageUrl: changedField == "post_image" && formData["post_image"] == null, - clearEngagement: shouldClearEngagement, - ); - } - - // Update session and generate image - await QuoteGeneratorApiV2.updateSession(_currentSessionId!, request); - - Uint8List imageBytes = await QuoteGeneratorApiV2.generateImage(_currentSessionId!); - - if (!mounted) return; - setState(() { - postPreviewImage = imageBytes; - }); - - } - - // parse number string, stripping commas first - int? _parseNum(String val) { - if (val.isEmpty) return null; - return int.tryParse(val.replaceAll(",", "")); - } - - // fill empty engagment fields with 1 if any field has value - // returns true if any fields were auto-filled - bool _fillEmptyEngagement() { - bool hasValue = formData["replies"] != "" || formData["retweets"] != "" || - formData["likes"] != "" || formData["views"] != ""; - - bool didFill = false; - - if (hasValue) { - if (formData["replies"] == "") { - formData["replies"] = "1"; - _repliesController.text = "1"; - didFill = true; - } - if (formData["retweets"] == "") { - formData["retweets"] = "1"; - _retweetsController.text = "1"; - didFill = true; - } - if (formData["likes"] == "") { - formData["likes"] = "1"; - _likesController.text = "1"; - didFill = true; - } - if (formData["views"] == "") { - formData["views"] = "1"; - _viewsController.text = "1"; - didFill = true; - } - } - - return didFill; - } - - QuoteEngagement? _buildEngagement(String field) { - // If field is "engagement", send all engagement fields (randomize button) - if (field == "engagement") { - // if all fields are empty, return null - if (formData["likes"] == "" && formData["retweets"] == "" && - formData["replies"] == "" && formData["views"] == "") { - return null; - } - - return QuoteEngagement( - likes: _parseNum(formData["likes"]), - retweets: _parseNum(formData["retweets"]), - replies: _parseNum(formData["replies"]), - views: _parseNum(formData["views"]), - ); - } - - // Otherwise only send the specific engagement field that changed - return QuoteEngagement( - likes: field == "engagement_likes" ? _parseNum(formData["likes"]) : null, - retweets: field == "engagement_retweets" ? _parseNum(formData["retweets"]) : null, - replies: field == "engagement_replies" ? _parseNum(formData["replies"]) : null, - views: field == "engagement_views" ? _parseNum(formData["views"]) : null, - ); - } - - Future shareImage() async { - if (postPreviewImage == null) { - print("No image to share"); + if (render.postPreviewImage == null) { + print("No media to share"); return; } try { - // get temporary directry + bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; + + final size = MediaQuery.of(context).size; + final tempDir = await getTemporaryDirectory(); - // create a unique filename with timestmp - final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}.png"; + final fileExt = isVideo ? "mp4" : "png"; + final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}.$fileExt"; final filePath = path.join(tempDir.path, fileName); - // write the image bytes to a temporry file final file = File(filePath); - await file.writeAsBytes(postPreviewImage!); + await file.writeAsBytes(render.postPreviewImage!); - - // Share the file using the systm share dialog final result = await Share.shareXFiles( [XFile(filePath)], - text: "Check out this quote!", + sharePositionOrigin: Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: 1, + height: 1, + ), ); - // optinal: log share result if (result.status == ShareResultStatus.success) { print("Share succesful"); - - // Send Discord webhook notifiation - // _sendShareNotification(); } } catch (e) { - print("Error sharin image: $e"); + print("Error sharin media: $e"); } } - // Future _sendShareNotification() async { - // const webhookUrl = "https://discord.com/api/webhooks/1456729319408664814/p2Ctqi7BijV0c34V-BnB62m6_yjkf72eD64oJyxXaNAcqZNfFm_-bA2uPZg1NTKpVdxp"; - // - // try { - // final response = await http.post( - // Uri.parse(webhookUrl), - // headers: {"Content-Type": "application/json"}, - // body: jsonEncode({ - // "content": "Someone just shared a quote from the app! 🎉", - // "embeds": [ - // { - // "title": "Quote Shared", - // "description": formData["content"].isNotEmpty - // ? "\"${formData["content"]}\"" - // : "A quote was shared", - // "color": 5814783, - // "fields": [ - // { - // "name": "User", - // "value": formData["display_name"].isNotEmpty - // ? "${formData["display_name"]} (@${formData["handle"]})" - // : "Anonymous", - // "inline": true - // }, - // { - // "name": "Timestamp", - // "value": DateTime.now().toIso8601String(), - // "inline": true - // } - // ], - // "footer": { - // "text": "QuoteGen App" - // } - // } - // ] - // }), - // ); - // - // if (response.statusCode == 204 || response.statusCode == 200) { - // print("Discord notificaton sent successfuly"); - // } else { - // print("Failed to send Discord notification: ${response.statusCode}"); - // } - // } catch (e) { - // print("Error sendin Discord webhook: $e"); - // } - // } + Future downloadMedia() async { + final render = context.read(); + + if (render.postPreviewImage == null) return; - // load form data from shared preferences - Future _loadFormData() async { try { - final prefs = await SharedPreferences.getInstance(); + bool isVideo = render.postPreviewContentType?.startsWith("video/") ?? false; + final ext = isVideo ? "mp4" : "png"; + final name = "tweetforge_${DateTime.now().millisecondsSinceEpoch}"; - if (!mounted) return; - - // load text fields - final displayName = prefs.getString("display_name") ?? ""; - var handle = prefs.getString("handle") ?? ""; - final content = prefs.getString("content") ?? ""; - final timestamp = prefs.getInt("timestamp") ?? 0; - final replies = prefs.getString("replies") ?? ""; - final retweets = prefs.getString("retweets") ?? ""; - final likes = prefs.getString("likes") ?? ""; - final views = prefs.getString("views") ?? ""; - final verified = prefs.getBool("verified") ?? false; - - // load images from base64 - Uint8List? avatarImage; - Uint8List? postImage; - - String? avatarBase64 = prefs.getString("avatar_image"); - if (avatarBase64 != null && avatarBase64.isNotEmpty) { - avatarImage = base64Decode(avatarBase64); - } - - String? postBase64 = prefs.getString("post_image"); - if (postBase64 != null && postBase64.isNotEmpty) { - postImage = base64Decode(postBase64); - } - - // split timestamp into seperate date and time - DateTime dateTime = timestamp > 0 - ? DateTime.fromMillisecondsSinceEpoch(timestamp) - : DateTime.now(); - - print("Loaded form data: name=$displayName, handle=$handle, content=$content"); - - setState(() { - formData["display_name"] = displayName; - formData["handle"] = handle; - formData["content"] = content; - formData["date"] = DateTime(dateTime.year, dateTime.month, dateTime.day); - formData["time"] = TimeOfDay(hour: dateTime.hour, minute: dateTime.minute); - formData["verified"] = verified; - formData["replies"] = replies; - formData["retweets"] = retweets; - formData["likes"] = likes; - formData["views"] = views; - formData["avatar_image"] = avatarImage; - formData["post_image"] = postImage; - - // Set controller text - _repliesController.text = replies; - _retweetsController.text = retweets; - _likesController.text = likes; - _viewsController.text = views; - }); - - // build engagment object if we have any values - QuoteEngagement? engagement; - if (likes != "" || retweets != "" || replies != "" || views != "") { - engagement = QuoteEngagement( - likes: _parseNum(likes), - retweets: _parseNum(retweets), - replies: _parseNum(replies), - views: _parseNum(views), - ); - } - - // create session with full form data (API expects timestamp in seconds) - // Use _combineDateTime() to get timestamp from formData (defaults to now if nothing saved) - QuoteSessionRequest initialRequest = QuoteSessionRequest( - displayName: displayName, - username: handle.toLowerCase(), - text: content, - timestamp: _combineDateTime(), - verified: verified, - avatarUrl: avatarImage, - imageUrl: postImage, - engagement: engagement, + await FileSaver.instance.saveFile( + name: name, + bytes: render.postPreviewImage!, + ext: ext, + mimeType: isVideo ? MimeType.mp4Video : MimeType.png, ); - - await _initializeSession(initialRequest); - - // genrate preview image if we have session - if (_currentSessionId != null) { - Uint8List imageBytes = await QuoteGeneratorApiV2.generateImage(_currentSessionId!); - if (!mounted) return; - setState(() { - postPreviewImage = imageBytes; - }); - } } catch (e) { - print("Error loading form data: $e"); - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } + print("Error downloading media: $e"); } } - - // 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"]}"); - - // combine date and time into timestamp (in milliseconds for storage) - int timestampMillis = _combineDateTime() * 1000; - - // save text felds - await prefs.setString("display_name", formData["display_name"]); - await prefs.setString("handle", formData["handle"]); - await prefs.setString("content", formData["content"]); - await prefs.setInt("timestamp", timestampMillis); - await prefs.setString("replies", formData["replies"]); - await prefs.setString("retweets", formData["retweets"]); - await prefs.setString("likes", formData["likes"]); - await prefs.setString("views", formData["views"]); - await prefs.setBool("verified", formData["verified"] ?? false); - - // save images as base64 - if (formData["avatar_image"] != null) { - String avatarBase64 = base64Encode(formData["avatar_image"]); - await prefs.setString("avatar_image", avatarBase64); - } else { - await prefs.remove("avatar_image"); - } - - if (formData["post_image"] != null) { - String postBase64 = base64Encode(formData["post_image"]); - await prefs.setString("post_image", postBase64); - } else { - await prefs.remove("post_image"); - } - - print("Form data saved successfully"); - } catch (e) { - print("Error savin form data: $e"); - } - } -} \ No newline at end of file +} diff --git a/lib/providers/backend_status_provider.dart b/lib/providers/backend_status_provider.dart new file mode 100644 index 0000000..2ac7c76 --- /dev/null +++ b/lib/providers/backend_status_provider.dart @@ -0,0 +1,42 @@ +import "dart:async"; + +import "package:flutter/foundation.dart"; +import "package:quotegen_client/services/quote_api_v2.dart"; + + +// tracks whether the render backend is reachable, so the ui can hide stuff +// that actually needs it (video render, snapshot links etc) when its down +class BackendStatusProvider extends ChangeNotifier { + + // start optimistic - dont want everything flashing away before the first check lands + bool isOnline = true; + bool hasChecked = false; + + Timer? _pollTimer; + bool _disposed = false; + + BackendStatusProvider() { + checkNow(); + _pollTimer = Timer.periodic(const Duration(seconds: 15), (_) => checkNow()); + } + + Future checkNow() async { + final online = await QuoteGeneratorApiV2.checkHealth(); + + if (_disposed) return; + + hasChecked = true; + + if (online != isOnline) { + isOnline = online; + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + _pollTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/form_provider.dart b/lib/providers/form_provider.dart new file mode 100644 index 0000000..f1c78d5 --- /dev/null +++ b/lib/providers/form_provider.dart @@ -0,0 +1,254 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:flutter/foundation.dart"; +import "package:quotegen_client/services/quote_api_v2.dart"; +import "package:shadcn_flutter/shadcn_flutter.dart"; +import "package:shared_preferences/shared_preferences.dart"; + + +class FormProvider extends ChangeNotifier { + + String displayName = ""; + String handle = ""; + String content = ""; + bool verified = false; + DateTime date = DateTime.now(); + TimeOfDay time = TimeOfDay.now(); + Uint8List? avatarImage; + Uint8List? postImage; + String replies = ""; + String retweets = ""; + String likes = ""; + String views = ""; + + int? savedTimestamp; + + + void setDisplayName(String v) { + displayName = v; + notifyListeners(); + } + + void setHandle(String v) { + handle = v; + notifyListeners(); + } + + void setContent(String v) { + content = v; + notifyListeners(); + } + + void setVerified(bool v) { + verified = v; + notifyListeners(); + } + + void setDate(DateTime v) { + date = v; + notifyListeners(); + } + + void setTime(TimeOfDay v) { + time = v; + notifyListeners(); + } + + void setAvatarImage(Uint8List? v) { + avatarImage = v; + notifyListeners(); + } + + void setPostImage(Uint8List? v) { + postImage = v; + notifyListeners(); + } + + void setReplies(String v) { + replies = v; + notifyListeners(); + } + + void setRetweets(String v) { + retweets = v; + notifyListeners(); + } + + void setLikes(String v) { + likes = v; + notifyListeners(); + } + + void setViews(String v) { + views = v; + notifyListeners(); + } + + void setSavedTimestamp(int? v) { + savedTimestamp = v; + notifyListeners(); + } + + // auto-fill empty engagement fields with "1" if any have a value + // returns true if anything was filled + bool fillEmptyEngagement() { + bool hasValue = replies != "" || retweets != "" || likes != "" || views != ""; + bool didFill = false; + + if (hasValue) { + if (replies == "") { replies = "1"; didFill = true; } + if (retweets == "") { retweets = "1"; didFill = true; } + if (likes == "") { likes = "1"; didFill = true; } + if (views == "") { views = "1"; didFill = true; } + } + + if (didFill) notifyListeners(); + return didFill; + } + + int combineDateTime() { + DateTime combined = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + return combined.millisecondsSinceEpoch ~/ 1000; + } + + int? _parseNum(String val) { + if (val.isEmpty) return null; + return int.tryParse(val.replaceAll(",", "")); + } + + QuoteEngagement? buildEngagement(String field) { + if (field == "engagement") { + if (likes == "" && retweets == "" && replies == "" && views == "") { + return null; + } + return QuoteEngagement( + likes: _parseNum(likes), + retweets: _parseNum(retweets), + replies: _parseNum(replies), + views: _parseNum(views), + ); + } + + return QuoteEngagement( + likes: field == "engagement_likes" ? _parseNum(likes) : null, + retweets: field == "engagement_retweets" ? _parseNum(retweets) : null, + replies: field == "engagement_replies" ? _parseNum(replies) : null, + views: field == "engagement_views" ? _parseNum(views) : null, + ); + } + + QuoteSessionRequest buildFullRequest() { + QuoteEngagement? engagement; + if (likes != "" || retweets != "" || replies != "" || views != "") { + engagement = QuoteEngagement( + likes: _parseNum(likes), + retweets: _parseNum(retweets), + replies: _parseNum(replies), + views: _parseNum(views), + ); + } + + return QuoteSessionRequest( + displayName: displayName, + username: handle.toLowerCase(), + text: content, + timestamp: combineDateTime(), + verified: verified, + avatarUrl: avatarImage, + imageUrl: postImage, + engagement: engagement, + ); + } + + // load form data from shared prefs + Future loadFormData() async { + try { + final prefs = await SharedPreferences.getInstance(); + + final dn = prefs.getString("display_name") ?? ""; + final h = prefs.getString("handle") ?? ""; + final c = prefs.getString("content") ?? ""; + final ts = prefs.getInt("timestamp") ?? 0; + final rep = prefs.getString("replies") ?? ""; + final ret = prefs.getString("retweets") ?? ""; + final lk = prefs.getString("likes") ?? ""; + final vw = prefs.getString("views") ?? ""; + final ver = prefs.getBool("verified") ?? false; + + if (ts > 0) savedTimestamp = ts; + + Uint8List? avatarImg; + Uint8List? postImg; + + String? avatarBase64 = prefs.getString("avatar_image"); + if (avatarBase64 != null && avatarBase64.isNotEmpty) { + avatarImg = base64Decode(avatarBase64); + } + + String? postBase64 = prefs.getString("post_image"); + if (postBase64 != null && postBase64.isNotEmpty) { + postImg = base64Decode(postBase64); + } + + print("Loaded form data: name=$dn, handle=$h"); + + displayName = dn; + handle = h; + content = c; + verified = ver; + replies = rep; + retweets = ret; + likes = lk; + views = vw; + avatarImage = avatarImg; + postImage = postImg; + + notifyListeners(); + } catch (e) { + print("Error loading form data: $e"); + } + } + + Future saveFormData() async { + try { + final prefs = await SharedPreferences.getInstance(); + + print("Saving form data: name=$displayName, handle=$handle"); + + int tsMillis = combineDateTime() * 1000; + + await prefs.setString("display_name", displayName); + await prefs.setString("handle", handle); + await prefs.setString("content", content); + await prefs.setInt("timestamp", tsMillis); + await prefs.setString("replies", replies); + await prefs.setString("retweets", retweets); + await prefs.setString("likes", likes); + await prefs.setString("views", views); + await prefs.setBool("verified", verified); + + if (avatarImage != null) { + await prefs.setString("avatar_image", base64Encode(avatarImage!)); + } else { + await prefs.remove("avatar_image"); + } + + if (postImage != null) { + await prefs.setString("post_image", base64Encode(postImage!)); + } else { + await prefs.remove("post_image"); + } + + print("Form data saved"); + } catch (e) { + print("Error savin form data: $e"); + } + } +} diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart new file mode 100644 index 0000000..0e42473 --- /dev/null +++ b/lib/providers/gallery_provider.dart @@ -0,0 +1,92 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:flutter/foundation.dart"; +import "package:quotegen_client/models/gallery_item.dart"; +import "package:quotegen_client/providers/form_provider.dart"; +import "package:quotegen_client/services/gallery_db.dart"; + + +class GalleryProvider extends ChangeNotifier { + + List items = []; + bool isLoading = false; + + + GalleryProvider() { + load(); + } + + Future load() async { + isLoading = true; + notifyListeners(); + + items = await GalleryDB.getAllItems(); + + isLoading = false; + notifyListeners(); + } + + // returns false if a duplicate already exists + Future saveCurrentForm(FormProvider form) async { + final avatarB64 = form.avatarImage != null ? base64Encode(form.avatarImage!) : null; + final postB64 = form.postImage != null ? base64Encode(form.postImage!) : null; + final ts = form.combineDateTime(); + + final isDuplicate = items.any((i) => + i.displayName == form.displayName && + i.handle == form.handle && + i.content == form.content && + i.verified == form.verified && + i.timestamp == ts && + i.avatarImageB64 == avatarB64 && + i.postImageB64 == postB64 && + i.replies == form.replies && + i.retweets == form.retweets && + i.likes == form.likes && + i.views == form.views + ); + + if (isDuplicate) return false; + + final id = DateTime.now().millisecondsSinceEpoch.toString(); + + final item = GalleryItem( + id: id, + displayName: form.displayName, + handle: form.handle, + content: form.content, + verified: form.verified, + timestamp: ts, + avatarImageB64: avatarB64, + postImageB64: postB64, + replies: form.replies, + retweets: form.retweets, + likes: form.likes, + views: form.views, + createdAt: DateTime.now().millisecondsSinceEpoch, + ); + + await GalleryDB.saveItem(item); + items.insert(0, item); + notifyListeners(); + return true; + } + + Future deleteItem(String id) async { + await GalleryDB.deleteItem(id); + items.removeWhere((i) => i.id == id); + notifyListeners(); + } + + // decode image bytes for a gallery item - returns null if no image + Uint8List? decodeAvatar(GalleryItem item) { + if (item.avatarImageB64 == null) return null; + return base64Decode(item.avatarImageB64!); + } + + Uint8List? decodePostImage(GalleryItem item) { + if (item.postImageB64 == null) return null; + return base64Decode(item.postImageB64!); + } +} diff --git a/lib/providers/render_provider.dart b/lib/providers/render_provider.dart new file mode 100644 index 0000000..68dc9ae --- /dev/null +++ b/lib/providers/render_provider.dart @@ -0,0 +1,240 @@ +import "dart:async"; +import "dart:io"; +import "dart:typed_data"; +import "dart:ui" as ui; + +import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; +import "package:path/path.dart" as path; +import "package:path_provider/path_provider.dart"; +import "package:quotegen_client/services/quote_api_v2.dart"; +import "package:video_player/video_player.dart"; + + +class RenderProvider extends ChangeNotifier { + + Uint8List? postPreviewImage; + String? postPreviewContentType; + + int renderProgress = 0; + String renderStatus = "idle"; + String renderStage = "idle"; + + VideoPlayerController? videoController; + bool videoLoadFailed = false; + + StreamSubscription? _sseSubscription; + bool _disposed = false; + + + void clearRender() { + if (postPreviewImage == null && renderStatus == "idle") return; + + videoController?.dispose(); + videoController = null; + + postPreviewImage = null; + postPreviewContentType = null; + renderStatus = "idle"; + renderProgress = 0; + renderStage = "idle"; + videoLoadFailed = false; + + if (!_disposed) notifyListeners(); + } + + Future renderOutput(String? sessionId, bool isVideoInForm, {GlobalKey? previewKey}) async { + if (!_disposed) { + renderProgress = 0; + renderStatus = "pending"; + renderStage = "idle"; + notifyListeners(); + } + + if (isVideoInForm) { + // video still goes through the server, so no session means theres genuinely nothing we can do + if (sessionId == null) { + print("Cant render video, session isnt ready (or never got created)"); + if (!_disposed) { + renderStatus = "failed"; + notifyListeners(); + } + return; + } + + print("Starting video render for session: $sessionId"); + + if (!_disposed) { + renderStatus = "rendering"; + renderStage = "rendering"; + notifyListeners(); + } + + // cancel any existing sub before starting new one + await _sseSubscription?.cancel(); + + _sseSubscription = QuoteGeneratorApiV2.watchRenderProgress(sessionId).listen( + (progress) { + print("Render progress: ${progress.status} - ${progress.stage} - ${progress.progress}%"); + + if (_disposed) return; + + renderProgress = progress.progress; + renderStatus = progress.status; + renderStage = progress.stage ?? "idle"; + notifyListeners(); + + if (progress.isComplete) { + print("Render complete, fetching media..."); + fetchRenderedMedia(sessionId); + } else if (progress.isFailed) { + print("Render failed: ${progress.error}"); + if (!_disposed) { + renderStatus = "failed"; + notifyListeners(); + } + } + }, + onError: (error) { + print("Error watching render progress: $error"); + }, + ); + + try { + await QuoteGeneratorApiV2.generateMedia(sessionId); + } catch (e) { + print("Error starting render: $e"); + } + + return; + } + + // image render - grab it straight off the live preview widget, dont need the server for this bit anymore + try { + if (previewKey == null) { + throw Exception("no preview key given, cant capture anything"); + } + + // renderStatus flipped to pending up top already, which swaps the ui back to the live + // tweet card - just gotta wait for that frame to actually paint before we grab it + await WidgetsBinding.instance.endOfFrame; + + final bytes = await _capturePreview(previewKey); + + if (_disposed) return; + + videoController?.dispose(); + videoController = null; + + postPreviewImage = bytes; + postPreviewContentType = "image/png"; + videoLoadFailed = false; + renderStatus = "completed"; + renderProgress = 100; + renderStage = "completed"; + notifyListeners(); + + } catch (e) { + print("Error rendering: $e"); + if (!_disposed) { + renderStatus = "failed"; + notifyListeners(); + } + } + } + + // rasterizes a repaint boundary to png, scaled to a fixed resolution regardless of its onscreen size + Future _capturePreview(GlobalKey key, {double targetSize = 1080}) async { + final renderObj = key.currentContext?.findRenderObject(); + + if (renderObj is! RenderRepaintBoundary) { + throw Exception("preview widget isnt mounted, nothing to capture"); + } + + final logicalSize = renderObj.size; + final pixelRatio = logicalSize.shortestSide == 0 ? 1.0 : targetSize / logicalSize.shortestSide; + + final image = await renderObj.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + + if (byteData == null) { + throw Exception("failed to encode captured png"); + } + + return byteData.buffer.asUint8List(); + } + + Future fetchRenderedMedia(String sessionId) async { + try { + MediaResponse media = await QuoteGeneratorApiV2.generateMedia(sessionId); + + if (_disposed) return; + + videoController?.dispose(); + videoController = null; + + postPreviewImage = media.bytes; + postPreviewContentType = media.contentType; + videoLoadFailed = false; + renderStatus = "completed"; + renderProgress = 100; + renderStage = "completed"; + notifyListeners(); + + if (media.isVideo) { + await initializeVideoPlayer(media.bytes); + } + + } catch (e) { + print("Error fetching rendered media: $e"); + if (!_disposed) { + renderStatus = "failed"; + notifyListeners(); + } + } + } + + Future initializeVideoPlayer(Uint8List videoBytes) async { + try { + videoLoadFailed = false; + + final tempDir = await getTemporaryDirectory(); + final videoPath = path.join(tempDir.path, "preview_video_${DateTime.now().millisecondsSinceEpoch}.mp4"); + final file = File(videoPath); + await file.writeAsBytes(videoBytes); + + print("Video saved to: $videoPath (${videoBytes.length} bytes)"); + + videoController = VideoPlayerController.file(file); + + videoController!.addListener(() { + if (!_disposed) notifyListeners(); + }); + + await videoController!.initialize(); + + videoController!.setLooping(true); + videoController!.play(); + + if (!_disposed) { + videoLoadFailed = false; + notifyListeners(); + } + } catch (e) { + print("Error initializing video player: $e"); + if (!_disposed) { + videoLoadFailed = true; + notifyListeners(); + } + } + } + + @override + void dispose() { + _disposed = true; + _sseSubscription?.cancel(); + videoController?.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart new file mode 100644 index 0000000..ac235ad --- /dev/null +++ b/lib/providers/session_provider.dart @@ -0,0 +1,113 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:quotegen_client/services/quote_api_v2.dart"; + + +class SessionProvider extends ChangeNotifier { + + String? currentSessionId; + bool isLoading = true; + bool canRender = true; + + Timer? _renderEnableTimer; + int _submitCount = 0; + + + Future initializeSession(QuoteSessionRequest request) async { + try { + QuoteSession session = await QuoteGeneratorApiV2.createSession(request); + currentSessionId = session.id; + print("Session created: ${session.id}"); + } catch (e) { + print("Failed to creat session: $e"); + } + } + + void setLoaded() { + isLoading = false; + notifyListeners(); + } + + void onSubmit({ + String? changedField, + required QuoteSessionRequest request, + VoidCallback? onDebounceSettled, + }) async { + + int currentSubmit = ++_submitCount; + + _renderEnableTimer?.cancel(); + if (canRender) { + canRender = false; + notifyListeners(); + } + + _renderEnableTimer = Timer(Duration(seconds: 5), () { + canRender = true; + notifyListeners(); + }); + + await Future.delayed(Duration(milliseconds: 700)); + + if (currentSubmit != _submitCount) return; + + // fire the callback so render gets cleared + onDebounceSettled?.call(); + + print("Submitting field: $changedField"); + + // wait for session to be ready + while (currentSessionId == null) { + await Future.delayed(Duration(milliseconds: 100)); + } + + QuoteSessionRequest updateRequest; + + if (changedField == null) { + updateRequest = request; + } else { + final engagement = (changedField == "engagement" || changedField.startsWith("engagement_")) + ? _buildPartialEngagement(changedField, request) + : null; + + final shouldClearEngagement = changedField == "engagement" && engagement == null; + + updateRequest = QuoteSessionRequest( + displayName: changedField == "display_name" ? request.displayName : null, + username: changedField == "handle" ? request.username : null, + text: changedField == "content" ? request.text : null, + timestamp: changedField == "datetime" ? request.timestamp : null, + avatarUrl: changedField == "avatar_image" ? request.avatarUrl : null, + imageUrl: changedField == "post_image" ? request.imageUrl : null, + verified: changedField == "verified" ? request.verified : null, + engagement: engagement, + clearAvatarUrl: changedField == "avatar_image" && request.avatarUrl == null, + clearImageUrl: changedField == "post_image" && request.imageUrl == null, + clearEngagement: shouldClearEngagement, + ); + } + + await QuoteGeneratorApiV2.updateSession(currentSessionId!, updateRequest); + print("Session updated"); + } + + QuoteEngagement? _buildPartialEngagement(String field, QuoteSessionRequest req) { + final eng = req.engagement; + if (field == "engagement") { + return eng; + } + return QuoteEngagement( + likes: field == "engagement_likes" ? eng?.likes : null, + retweets: field == "engagement_retweets" ? eng?.retweets : null, + replies: field == "engagement_replies" ? eng?.replies : null, + views: field == "engagement_views" ? eng?.views : null, + ); + } + + @override + void dispose() { + _renderEnableTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/services/gallery_db.dart b/lib/services/gallery_db.dart new file mode 100644 index 0000000..69570a8 --- /dev/null +++ b/lib/services/gallery_db.dart @@ -0,0 +1,39 @@ +import "package:hive_flutter/hive_flutter.dart"; +import "package:quotegen_client/models/gallery_item.dart"; + +class GalleryDB { + + static const String _boxName = "gallery"; + + static LazyBox get _box => Hive.lazyBox(_boxName); + + + static Future saveItem(GalleryItem item) async { + await _box.put(item.id, item.toJsonString()); + } + + static Future deleteItem(String id) async { + await _box.delete(id); + } + + static Future> getAllItems() async { + final keys = _box.keys.toList(); + final items = []; + + for (final key in keys) { + final raw = await _box.get(key); + if (raw != null) { + try { + items.add(GalleryItem.fromJsonString(raw)); + } catch (e) { + // skip corrupted entries + print("GalleryDB: failed to parse item $key: $e"); + } + } + } + + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return items; + } + +} diff --git a/lib/services/quote_api_v2.dart b/lib/services/quote_api_v2.dart index 639d0e3..165cea9 100644 --- a/lib/services/quote_api_v2.dart +++ b/lib/services/quote_api_v2.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:convert"; import "dart:typed_data"; import "package:flutter/foundation.dart"; @@ -66,13 +67,44 @@ class QuoteSessionRequest { }); - // convert img bytes to base64 data uri + // convert img/video 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"; + + // detect mime type from magic bytes + String mimeType = "image/png"; // default + + if (image.length >= 4) { + // MP4: starts with ftyp box + if (image.length >= 12 && + image[4] == 0x66 && image[5] == 0x74 && + image[6] == 0x79 && image[7] == 0x70) { + mimeType = "video/mp4"; + } + // WebM: starts with 0x1A45DFA3 + else if (image[0] == 0x1A && image[1] == 0x45 && + image[2] == 0xDF && image[3] == 0xA3) { + mimeType = "video/webm"; + } + // GIF: starts with GIF87a or GIF89a + else if (image[0] == 0x47 && image[1] == 0x49 && image[2] == 0x46) { + mimeType = "image/gif"; + } + // PNG: starts with 0x89504E47 + else if (image[0] == 0x89 && image[1] == 0x50 && + image[2] == 0x4E && image[3] == 0x47) { + mimeType = "image/png"; + } + // JPEG: starts with 0xFFD8FF + else if (image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF) { + mimeType = "image/jpeg"; + } + } + + return "data:$mimeType;base64,$base64String"; } return null; } @@ -123,6 +155,10 @@ class QuoteSession { final int? timestamp; final bool? verified; final QuoteEngagement? engagement; + final String? renderStatus; + final int? renderProgress; + final int? renderPid; + final String? renderError; final int createdAt; final int updatedAt; @@ -136,6 +172,10 @@ class QuoteSession { this.timestamp, this.verified, this.engagement, + this.renderStatus, + this.renderProgress, + this.renderPid, + this.renderError, required this.createdAt, required this.updatedAt, }); @@ -153,10 +193,23 @@ class QuoteSession { engagement: json["engagement"] != null ? QuoteEngagement.fromJson(json["engagement"]) : null, + renderStatus: json["renderStatus"], + renderProgress: json["renderProgress"], + renderPid: json["renderPid"], + renderError: json["renderError"], createdAt: json["createdAt"], updatedAt: json["updatedAt"], ); } + + // helper to check if render is complete + bool get isRenderComplete => renderStatus == "completed"; + + // helper to check if render failed + bool get isRenderFailed => renderStatus == "failed"; + + // helper to check if render is in progress + bool get isRendering => renderStatus == "rendering" || renderStatus == "pending"; } @@ -187,9 +240,72 @@ class QuoteSnapshot { } +class RenderProgress { + final String status; + final String sessionId; + final int progress; + final String? stage; + final String? error; + + RenderProgress({ + required this.status, + required this.sessionId, + required this.progress, + this.stage, + this.error, + }); + + factory RenderProgress.fromJson(Map json) { + return RenderProgress( + status: json["status"], + sessionId: json["sessionId"], + progress: json["progress"], + stage: json["stage"], + error: json["error"], + ); + } + + bool get isComplete => status == "completed"; + bool get isFailed => status == "failed"; + bool get isRendering => status == "rendering" || status == "pending"; + + // helper to get stage display text + String get stageText { + switch (stage) { + case "rendering": + return "Rendering video..."; + case "encoding": + return "Encoding to H.264..."; + case "completed": + return "Complete!"; + default: + return "Processing..."; + } + } +} + + +class MediaResponse { + final Uint8List bytes; + final String contentType; + final String? sessionId; + final bool? cacheHit; + + MediaResponse({ + required this.bytes, + required this.contentType, + this.sessionId, + this.cacheHit, + }); + + bool get isVideo => contentType.startsWith("video/"); + bool get isImage => contentType.startsWith("image/"); +} + + class QuoteGeneratorApiV2 { // static const String _baseUrl = "https://quotes.imbenji.net"; - static const String _baseUrl = kDebugMode ? "http://localhost:3000" : "https://quotes.imbenji.net"; + static final String _baseUrl = kDebugMode && defaultTargetPlatform == TargetPlatform.iOS ? "http://localhost:3000" : "https://quotes.imbenji.net"; // create new session @@ -255,8 +371,8 @@ class QuoteGeneratorApiV2 { } } - // render the session as image - static Future generateImage(String sessionId) async { + // render the session as image or video (returns full response info) + static Future generateMedia(String sessionId) async { final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image"); final response = await http.get( @@ -265,12 +381,23 @@ class QuoteGeneratorApiV2 { ); if (response.statusCode == 200) { - return response.bodyBytes; + return MediaResponse( + bytes: response.bodyBytes, + contentType: response.headers["content-type"] ?? "image/png", + sessionId: response.headers["x-session-id"], + cacheHit: response.headers["x-cache"] == "HIT", + ); } else { - throw Exception("Failed to generate image: ${response.statusCode}"); + throw Exception("Failed to generate media: ${response.statusCode}"); } } + // render the session as image (legacy method, returns just bytes) + static Future generateImage(String sessionId) async { + final media = await generateMedia(sessionId); + return media.bytes; + } + // delete session static Future deleteSession(String sessionId) async { @@ -304,8 +431,8 @@ class QuoteGeneratorApiV2 { } } - // get snapshot image by token - static Future getSnapshot(String token) async { + // get snapshot media by token (returns full response info) + static Future getSnapshotMedia(String token) async { final url = Uri.parse("$_baseUrl/v2/snapshot/$token"); final response = await http.get( @@ -314,12 +441,118 @@ class QuoteGeneratorApiV2 { ); if (response.statusCode == 200) { - return response.bodyBytes; + return MediaResponse( + bytes: response.bodyBytes, + contentType: response.headers["content-type"] ?? "image/png", + sessionId: response.headers["x-snapshot-token"], + cacheHit: response.headers["x-cache"] == "HIT", + ); } else { throw Exception("Failed to get snapshot: ${response.statusCode}"); } } + // get snapshot image by token (legacy method, returns just bytes) + static Future getSnapshot(String token) async { + final media = await getSnapshotMedia(token); + return media.bytes; + } + + + // stream render progress via SSE + static Stream watchRenderProgress(String sessionId) async* { + final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/render-info"); + print("Connecting to SSE: $url"); + + final request = http.Request("GET", url); + request.headers["User-Agent"] = "QuoteGen-Flutter/1.0"; + request.headers["Accept"] = "text/event-stream"; + request.headers["Cache-Control"] = "no-cache"; + + final client = http.Client(); + + try { + final response = await client.send(request); + print("SSE response status: ${response.statusCode}"); + + if (response.statusCode != 200) { + throw Exception("Failed to watch render progress: ${response.statusCode}"); + } + + // buffer for incomplete SSE messages + String buffer = ""; + + await for (final chunk in response.stream.transform(utf8.decoder)) { + buffer += chunk; + print("SSE chunk received: $chunk"); + + // split by double newlines (SSE message boundry) + final messages = buffer.split("\n\n"); + + // last part might be incomplete, keep it in buffer + buffer = messages.last; + + // process complete messages + for (int i = 0; i < messages.length - 1; i++) { + final message = messages[i].trim(); + + if (message.isEmpty) continue; + + // parse SSE data field (format: "data: {...}") + if (message.startsWith("data:")) { + final jsonString = message.substring(5).trim(); + print("SSE data: $jsonString"); + + try { + final data = jsonDecode(jsonString); + final progress = RenderProgress.fromJson(data); + print("Parsed progress: status=${progress.status}, stage=${progress.stage}, progress=${progress.progress}%"); + yield progress; + + // close stream when render is done + if (progress.isComplete || progress.isFailed) { + print("Render finished, closing SSE stream"); + client.close(); + return; + } + } catch (e) { + print("Error parsing SSE JSON: $e"); + // ignore malformed JSON + continue; + } + } + } + } + } catch (e) { + print("SSE stream error: $e"); + rethrow; + } finally { + client.close(); + print("SSE connection closed"); + } + } + + + // ping the backend to see if its actually up, used to hide server dependant features when its not + static Future checkHealth() async { + try { + final url = Uri.parse("$_baseUrl/health"); + final response = await http.get( + url, + headers: {"User-Agent": "QuoteGen-Flutter/1.0"}, + ).timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data["status"] == "ok"; + } + return false; + } catch (e) { + print("Health check failed: $e"); + return false; + } + } + // helper to get current timestamp in seconds static int getCurrentTimestamp() { diff --git a/lib/widgets/tweet_template_widget.dart b/lib/widgets/tweet_template_widget.dart new file mode 100644 index 0000000..5482889 --- /dev/null +++ b/lib/widgets/tweet_template_widget.dart @@ -0,0 +1,403 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:video_player/video_player.dart'; +import 'package:path_provider/path_provider.dart'; + +class TweetTemplateWidget extends StatefulWidget { + final Uint8List? avatarBytes; + final String displayName; + final bool isVerified; + final String username; + final String text; + final Uint8List? imageBytes; + final Uint8List? videoBytes; + final DateTime timestamp; + final Map? engagement; // {replies, retweets, likes, views} + + const TweetTemplateWidget({ + Key? key, + this.avatarBytes, + required this.displayName, + this.isVerified = false, + required this.username, + required this.text, + this.imageBytes, + this.videoBytes, + required this.timestamp, + this.engagement, + }) : assert(imageBytes == null || videoBytes == null, + 'Cannot have both imageBytes and videoBytes set at the same time'), + super(key: key); + + @override + State createState() => _TweetTemplateWidgetState(); +} + +class _TweetTemplateWidgetState extends State { + VideoPlayerController? _videoController; + File? _tempVideoFile; + + String get _normalizedUsername => _normalizeUsername(widget.username); + + @override + void initState() { + super.initState(); + _initializeMedia(); + } + + String _normalizeUsername(String username) { + final trimmed = username.trim(); + + // if empty or just "@", return default + if (trimmed.isEmpty || trimmed == '@') return '@anonymous'; + + // add @ if it doesnt start with it + return trimmed.startsWith('@') ? trimmed : '@$trimmed'; + } + + Future _initializeMedia() async { + if (widget.videoBytes == null) return; + + // write video bytes to a temp file + final tempDir = await getTemporaryDirectory(); + final tempPath = '${tempDir.path}/temp_video_${DateTime.now().millisecondsSinceEpoch}.mp4'; + _tempVideoFile = File(tempPath); + await _tempVideoFile!.writeAsBytes(widget.videoBytes!); + + _videoController = VideoPlayerController.file(_tempVideoFile!) + ..initialize().then((_) { + if (mounted) { + setState(() {}); + _videoController?.play(); + _videoController?.setLooping(true); + _videoController?.setVolume(0.0); + } + }); + } + + @override + void dispose() { + _videoController?.dispose(); + _tempVideoFile?.delete().catchError((_) {}); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest.shortestSide - 20; + + return Container( + width: size, + height: size, + color: Colors.black, + child: Center( + child: FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: 450, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + color: Colors.black, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header with avatar and user info + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + Container( + width: 40, + height: 40, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: const Color.fromRGBO(51, 54, 57, 1), + borderRadius: BorderRadius.circular(20), + ), + child: widget.avatarBytes != null + ? ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.memory( + widget.avatarBytes!, + fit: BoxFit.cover, + ), + ) + : null, + ), + // user info section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.displayName, + style: const TextStyle( + fontFamily: '-apple-system', + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color.fromRGBO(231, 233, 234, 1), + height: 1.33, + ), + ), + if (widget.isVerified) ...[ + const SizedBox(width: 4), + SvgPicture.string( + _verifiedBadgeSvg, + width: 18, + height: 18, + ), + ], + ], + ), + Text( + _normalizedUsername, + style: const TextStyle( + fontFamily: '-apple-system', + fontSize: 15, + color: Color.fromRGBO(113, 118, 123, 1), + height: 1.33, + ), + ), + ], + ), + ], + ), + + + const SizedBox(height: 12), + // tweet text + Text( + widget.text, + style: const TextStyle( + fontFamily: '-apple-system', + fontSize: 15, + color: Color.fromRGBO(231, 233, 234, 1), + height: 1.33, + ), + ), + const SizedBox(height: 12), + + // media content (image or vdeo) + if (widget.imageBytes != null) ...[ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color.fromRGBO(47, 51, 54, 1), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.memory( + widget.imageBytes!, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 12), + ], + + if (widget.videoBytes != null) ...[ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color.fromRGBO(47, 51, 54, 1), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: (_videoController?.value.isInitialized ?? false) + ? AspectRatio( + aspectRatio: _videoController!.value.aspectRatio, + child: VideoPlayer(_videoController!), + ) + : Container( + height: 200, + color: const Color.fromRGBO(51, 54, 57, 1), + child: const Center( + child: CircularProgressIndicator( + color: Color.fromRGBO(29, 155, 240, 1), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + + // timestmp and source + RichText( + text: TextSpan( + style: const TextStyle( + fontFamily: '-apple-system', + fontSize: 15, + color: Color.fromRGBO(113, 118, 123, 1), + ), + children: [ + TextSpan(text: _formatTimestamp(widget.timestamp)), + const TextSpan(text: ' · '), + const TextSpan( + text: 'via tweetforge.imbenji.net', + style: TextStyle( + color: Color.fromRGBO(29, 155, 240, 1), + ), + ), + ], + ), + ), + + // engagement metrics if provided + if (widget.engagement != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.only(top: 12), + decoration: const BoxDecoration( + border: Border( + top: BorderSide( + color: Color.fromRGBO(47, 51, 54, 1), + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.engagement!['replies'] != null) + _buildEngagementItem( + _replyIcon, + widget.engagement!['replies']!, + ), + if (widget.engagement!['retweets'] != null) + _buildEngagementItem( + _retweetIcon, + widget.engagement!['retweets']!, + ), + if (widget.engagement!['likes'] != null) + _buildEngagementItem( + _likeIcon, + widget.engagement!['likes']!, + ), + if (widget.engagement!['views'] != null) + _buildEngagementItem( + _viewsIcon, + widget.engagement!['views']!, + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildEngagementItem(String iconSvg, int count) { + return Row( + children: [ + SvgPicture.string( + iconSvg, + width: 18.75, + height: 18.75, + colorFilter: const ColorFilter.mode( + Color.fromRGBO(113, 118, 123, 1), + BlendMode.srcIn, + ), + ), + const SizedBox(width: 4), + Text( + _formatCount(count), + style: const TextStyle( + fontFamily: '-apple-system', + fontSize: 13, + color: Color.fromRGBO(113, 118, 123, 1), + ), + ), + ], + ); + } + + String _formatCount(int count) { + if (count >= 1000000000) { + final formatted = (count / 1000000000).toStringAsFixed(1); + return formatted.replaceAll(RegExp(r'\.0$'), '') + 'B'; + } + if (count >= 1000000) { + final formatted = (count / 1000000).toStringAsFixed(1); + return formatted.replaceAll(RegExp(r'\.0$'), '') + 'M'; + } + if (count >= 1000) { + final formatted = (count / 1000).toStringAsFixed(1); + return formatted.replaceAll(RegExp(r'\.0$'), '') + 'K'; + } + return count.toString(); + } + + String _formatTimestamp(DateTime date) { + final hours = date.hour; + final minutes = date.minute.toString().padLeft(2, '0'); + final ampm = hours >= 12 ? 'PM' : 'AM'; + final hour12 = hours % 12 == 0 ? 12 : hours % 12; + + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final month = months[date.month - 1]; + final day = date.day; + final year = date.year; + + return '$hour12:$minutes $ampm · $month $day, $year'; + } + + // verified badge svg path + static const String _verifiedBadgeSvg = ''' + + + +'''; + + // engagment icons (simplified versions) + static const String _replyIcon = ''' + + + +'''; + + static const String _retweetIcon = ''' + + + +'''; + + static const String _likeIcon = ''' + + + +'''; + + static const String _viewsIcon = ''' + + + +'''; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cd13034..68607c1 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_saver import path_provider_foundation import share_plus import shared_preferences_foundation +import video_player_avfoundation import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index df166f2..bd0cefa 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,6 +1,8 @@ PODS: - file_picker (0.0.1): - FlutterMacOS + - file_saver (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter @@ -10,21 +12,28 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/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`) + - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_saver: + :path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: @@ -33,15 +42,19 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + video_player_avfoundation: + :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin webview_flutter_wkwebview: :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + file_saver: 44e6fbf666677faf097302460e214e977fdd977b FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba share_plus: 1fa619de8392a4398bfaf176d441853922614e89 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86 webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index ece0df5..05fd049 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animation_kit: + dependency: transitive + description: + name: animation_kit + sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63 + url: "https://pub.dev" + source: hosted + version: "0.0.2" archive: dependency: transitive description: @@ -37,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -117,10 +125,10 @@ packages: dependency: transitive description: name: data_widget - sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba" + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.3" dio: dependency: transitive description: @@ -214,6 +222,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -222,6 +235,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -256,6 +277,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.3.1" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" html: dependency: transitive description: @@ -292,10 +329,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" jovial_misc: dependency: transitive description: @@ -356,26 +393,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -384,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -392,6 +437,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -480,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" quiver: dependency: transitive description: @@ -500,10 +561,10 @@ packages: dependency: "direct main" description: name: shadcn_flutter - sha256: "1fd4f798c39d6308dc8f7e94d9e870b5db39fbf417ea95c423c7555ce8227a1c" + sha256: "3a5d92981a0504dd5e42b1f08e413233dbeaa5d075253bb500fe5e71bb01aadc" url: "https://pub.dev" source: hosted - version: "0.0.47" + version: "0.0.49" share_plus: dependency: "direct main" description: @@ -633,10 +694,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" typed_data: dependency: transitive description: @@ -701,6 +762,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + url: "https://pub.dev" + source: hosted + version: "1.1.20" vector_math: dependency: transitive description: @@ -709,6 +794,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a + url: "https://pub.dev" + source: hosted + version: "2.9.1" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4 + url: "https://pub.dev" + source: hosted + version: "2.8.9" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 63fde08..30cfc73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,7 @@ environment: dependencies: flutter: sdk: flutter - shadcn_flutter: ^0.0.47 + shadcn_flutter: ^0.0.49 go_router: ^17.0.1 http: ^1.2.2 file_picker: ^8.1.6 @@ -38,8 +38,15 @@ dependencies: share_plus: ^10.1.4 path_provider: ^2.1.5 shared_preferences: ^2.3.4 - intl: ^0.19.0 + intl: ^0.20.2 file_saver: ^0.2.14 + video_player: ^2.9.2 + flutter_svg: ^2.0.0 + provider: ^6.1.5+1 + + + hive: ^2.2.3 + hive_flutter: ^1.1.0 # Ad integration google_mobile_ads: ^5.2.0