feat: add gallery feature with database integration and rendering capabilities
Build Android App / build (push) Failing after 2m19s
Build Android App / build (push) Failing after 2m19s
This commit is contained in:
@@ -33,6 +33,8 @@ PODS:
|
|||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- file_saver (0.0.1):
|
||||||
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- Google-Mobile-Ads-SDK (11.13.0):
|
- Google-Mobile-Ads-SDK (11.13.0):
|
||||||
- GoogleUserMessagingPlatform (>= 1.1)
|
- GoogleUserMessagingPlatform (>= 1.1)
|
||||||
@@ -53,17 +55,22 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- SwiftyGif (5.4.5)
|
- SwiftyGif (5.4.5)
|
||||||
|
- video_player_avfoundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
|
- file_saver (from `.symlinks/plugins/file_saver/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- 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`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -78,6 +85,8 @@ SPEC REPOS:
|
|||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
|
file_saver:
|
||||||
|
:path: ".symlinks/plugins/file_saver/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
google_mobile_ads:
|
google_mobile_ads:
|
||||||
@@ -88,6 +97,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
video_player_avfoundation:
|
||||||
|
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
@@ -95,6 +106,7 @@ SPEC CHECKSUMS:
|
|||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||||
|
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407
|
Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407
|
||||||
google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957
|
google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957
|
||||||
@@ -104,6 +116,7 @@ SPEC CHECKSUMS:
|
|||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
|
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
|
||||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|||||||
+108
-10
@@ -1,7 +1,15 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:go_router/go_router.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/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:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
@@ -13,6 +21,9 @@ void main() async {
|
|||||||
await MobileAds.instance.initialize();
|
await MobileAds.instance.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Hive.initFlutter();
|
||||||
|
await Hive.openLazyBox<String>("gallery");
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,24 +33,111 @@ class MyApp extends StatelessWidget {
|
|||||||
// This widget is the root of your application.
|
// This widget is the root of your application.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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',
|
title: 'TweetForge',
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
scaling: defaultTargetPlatform == TargetPlatform.android ? AdaptiveScaling.only(
|
scaling: {
|
||||||
sizeScaling: 1,
|
TargetPlatform.iOS: AdaptiveScaling.only(
|
||||||
textScaling: 1.1,
|
sizeScaling: 1.3,
|
||||||
radiusScaling: 1.1
|
textScaling: 1.2,
|
||||||
) : null,
|
radiusScaling: 1.3
|
||||||
|
),
|
||||||
|
TargetPlatform.android: AdaptiveScaling.only(
|
||||||
|
sizeScaling: 1.2,
|
||||||
|
textScaling: 1.02,
|
||||||
|
radiusScaling: 1.0
|
||||||
|
),
|
||||||
|
}[defaultTargetPlatform] ?? AdaptiveScaling.mobile,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorSchemes.darkBlue,
|
colorScheme: ColorSchemes.darkGray.indigo,
|
||||||
|
surfaceOpacity: 1,
|
||||||
|
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GoRouter _router = GoRouter(
|
GoRouter _router = GoRouter(
|
||||||
initialLocation: "/",
|
initialLocation: "/",
|
||||||
routes: [
|
routes: [
|
||||||
HomePage.route
|
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return _AppShell(child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
HomePage.route,
|
||||||
|
GalleryPage.route,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic>);
|
||||||
|
}
|
||||||
@@ -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<GalleryProvider>();
|
||||||
|
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<GalleryProvider>().deleteItem(item.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTap(BuildContext context) async {
|
||||||
|
final form = context.read<FormProvider>();
|
||||||
|
final session = context.read<SessionProvider>();
|
||||||
|
final gallery = context.read<GalleryProvider>();
|
||||||
|
|
||||||
|
bool hasContent = form.displayName.isNotEmpty ||
|
||||||
|
form.handle.isNotEmpty ||
|
||||||
|
form.content.isNotEmpty ||
|
||||||
|
form.avatarImage != null ||
|
||||||
|
form.postImage != null;
|
||||||
|
|
||||||
|
if (hasContent) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+874
-847
File diff suppressed because it is too large
Load Diff
@@ -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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GalleryItem> items = [];
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
|
|
||||||
|
GalleryProvider() {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
items = await GalleryDB.getAllItems();
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns false if a duplicate already exists
|
||||||
|
Future<bool> 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<void> 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<Uint8List> _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<void> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> get _box => Hive.lazyBox<String>(_boxName);
|
||||||
|
|
||||||
|
|
||||||
|
static Future<void> saveItem(GalleryItem item) async {
|
||||||
|
await _box.put(item.id, item.toJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteItem(String id) async {
|
||||||
|
await _box.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<GalleryItem>> getAllItems() async {
|
||||||
|
final keys = _box.keys.toList();
|
||||||
|
final items = <GalleryItem>[];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+243
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import "dart:async";
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
import "package:flutter/foundation.dart";
|
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) {
|
String? _encodeImage(dynamic image) {
|
||||||
if (image == null) return null;
|
if (image == null) return null;
|
||||||
if (image is String) return image;
|
if (image is String) return image;
|
||||||
if (image is Uint8List) {
|
if (image is Uint8List) {
|
||||||
final base64String = base64Encode(image);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -123,6 +155,10 @@ class QuoteSession {
|
|||||||
final int? timestamp;
|
final int? timestamp;
|
||||||
final bool? verified;
|
final bool? verified;
|
||||||
final QuoteEngagement? engagement;
|
final QuoteEngagement? engagement;
|
||||||
|
final String? renderStatus;
|
||||||
|
final int? renderProgress;
|
||||||
|
final int? renderPid;
|
||||||
|
final String? renderError;
|
||||||
final int createdAt;
|
final int createdAt;
|
||||||
final int updatedAt;
|
final int updatedAt;
|
||||||
|
|
||||||
@@ -136,6 +172,10 @@ class QuoteSession {
|
|||||||
this.timestamp,
|
this.timestamp,
|
||||||
this.verified,
|
this.verified,
|
||||||
this.engagement,
|
this.engagement,
|
||||||
|
this.renderStatus,
|
||||||
|
this.renderProgress,
|
||||||
|
this.renderPid,
|
||||||
|
this.renderError,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -153,10 +193,23 @@ class QuoteSession {
|
|||||||
engagement: json["engagement"] != null
|
engagement: json["engagement"] != null
|
||||||
? QuoteEngagement.fromJson(json["engagement"])
|
? QuoteEngagement.fromJson(json["engagement"])
|
||||||
: null,
|
: null,
|
||||||
|
renderStatus: json["renderStatus"],
|
||||||
|
renderProgress: json["renderProgress"],
|
||||||
|
renderPid: json["renderPid"],
|
||||||
|
renderError: json["renderError"],
|
||||||
createdAt: json["createdAt"],
|
createdAt: json["createdAt"],
|
||||||
updatedAt: json["updatedAt"],
|
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<String, dynamic> 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 {
|
class QuoteGeneratorApiV2 {
|
||||||
// static const String _baseUrl = "https://quotes.imbenji.net";
|
// 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
|
// create new session
|
||||||
@@ -255,8 +371,8 @@ class QuoteGeneratorApiV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// render the session as image
|
// render the session as image or video (returns full response info)
|
||||||
static Future<Uint8List> generateImage(String sessionId) async {
|
static Future<MediaResponse> generateMedia(String sessionId) async {
|
||||||
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image");
|
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image");
|
||||||
|
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -265,12 +381,23 @@ class QuoteGeneratorApiV2 {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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 {
|
} 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<Uint8List> generateImage(String sessionId) async {
|
||||||
|
final media = await generateMedia(sessionId);
|
||||||
|
return media.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// delete session
|
// delete session
|
||||||
static Future<void> deleteSession(String sessionId) async {
|
static Future<void> deleteSession(String sessionId) async {
|
||||||
@@ -304,8 +431,8 @@ class QuoteGeneratorApiV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get snapshot image by token
|
// get snapshot media by token (returns full response info)
|
||||||
static Future<Uint8List> getSnapshot(String token) async {
|
static Future<MediaResponse> getSnapshotMedia(String token) async {
|
||||||
final url = Uri.parse("$_baseUrl/v2/snapshot/$token");
|
final url = Uri.parse("$_baseUrl/v2/snapshot/$token");
|
||||||
|
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -314,12 +441,118 @@ class QuoteGeneratorApiV2 {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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 {
|
} else {
|
||||||
throw Exception("Failed to get snapshot: ${response.statusCode}");
|
throw Exception("Failed to get snapshot: ${response.statusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get snapshot image by token (legacy method, returns just bytes)
|
||||||
|
static Future<Uint8List> getSnapshot(String token) async {
|
||||||
|
final media = await getSnapshotMedia(token);
|
||||||
|
return media.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// stream render progress via SSE
|
||||||
|
static Stream<RenderProgress> 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<bool> 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
|
// helper to get current timestamp in seconds
|
||||||
static int getCurrentTimestamp() {
|
static int getCurrentTimestamp() {
|
||||||
|
|||||||
@@ -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<String, int>? 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<TweetTemplateWidget> createState() => _TweetTemplateWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TweetTemplateWidgetState extends State<TweetTemplateWidget> {
|
||||||
|
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<void> _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 = '''
|
||||||
|
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#1D9BF0" d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"/>
|
||||||
|
</svg>
|
||||||
|
''';
|
||||||
|
|
||||||
|
// engagment icons (simplified versions)
|
||||||
|
static const String _replyIcon = '''
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"/>
|
||||||
|
</svg>
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String _retweetIcon = '''
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String _likeIcon = '''
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/>
|
||||||
|
</svg>
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String _viewsIcon = '''
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"/>
|
||||||
|
</svg>
|
||||||
|
''';
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import file_saver
|
|||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import video_player_avfoundation
|
||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- file_saver (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -10,21 +12,28 @@ PODS:
|
|||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- video_player_avfoundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
|
- file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- 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`)
|
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
|
file_saver:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -33,15 +42,19 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
|
video_player_avfoundation:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
|
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
|
||||||
|
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
|
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
|
||||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|||||||
+141
-16
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
animation_kit:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: animation_kit
|
||||||
|
sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -37,10 +45,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -117,10 +125,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: data_widget
|
name: data_widget
|
||||||
sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba"
|
sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.2"
|
version: "0.0.3"
|
||||||
dio:
|
dio:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -214,6 +222,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -222,6 +235,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.33"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -256,6 +277,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.3.1"
|
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:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -292,10 +329,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.20.2"
|
||||||
jovial_misc:
|
jovial_misc:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -356,26 +393,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +421,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -392,6 +437,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -480,6 +533,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
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:
|
quiver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -500,10 +561,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shadcn_flutter
|
name: shadcn_flutter
|
||||||
sha256: "1fd4f798c39d6308dc8f7e94d9e870b5db39fbf417ea95c423c7555ce8227a1c"
|
sha256: "3a5d92981a0504dd5e42b1f08e413233dbeaa5d075253bb500fe5e71bb01aadc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.47"
|
version: "0.0.49"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -633,10 +694,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +762,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -709,6 +794,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+9
-2
@@ -30,7 +30,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
shadcn_flutter: ^0.0.47
|
shadcn_flutter: ^0.0.49
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
file_picker: ^8.1.6
|
file_picker: ^8.1.6
|
||||||
@@ -38,8 +38,15 @@ dependencies:
|
|||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
shared_preferences: ^2.3.4
|
shared_preferences: ^2.3.4
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
file_saver: ^0.2.14
|
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
|
# Ad integration
|
||||||
google_mobile_ads: ^5.2.0
|
google_mobile_ads: ^5.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user