feat: add gallery feature with database integration and rendering capabilities
Build Android App / build (push) Failing after 2m19s

This commit is contained in:
ImBenji
2026-08-25 11:46:33 +01:00
parent b66b73a3c6
commit 8c2212b563
17 changed files with 2902 additions and 885 deletions
+13
View File
@@ -33,6 +33,8 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- file_saver (0.0.1):
- Flutter
- Flutter (1.0.0)
- Google-Mobile-Ads-SDK (11.13.0):
- GoogleUserMessagingPlatform (>= 1.1)
@@ -53,17 +55,22 @@ PODS:
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`)
- Flutter (from `Flutter`)
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
@@ -78,6 +85,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
file_saver:
:path: ".symlinks/plugins/file_saver/ios"
Flutter:
:path: Flutter
google_mobile_ads:
@@ -88,6 +97,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
@@ -95,6 +106,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407
google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957
@@ -104,6 +116,7 @@ SPEC CHECKSUMS:
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+107 -9
View File
@@ -1,7 +1,15 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'package:quotegen_client/pages/gallery/page.dart';
import 'package:quotegen_client/pages/home/page.dart';
import 'package:quotegen_client/providers/backend_status_provider.dart';
import 'package:quotegen_client/providers/form_provider.dart';
import 'package:quotegen_client/providers/gallery_provider.dart';
import 'package:quotegen_client/providers/render_provider.dart';
import 'package:quotegen_client/providers/session_provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
@@ -13,6 +21,9 @@ void main() async {
await MobileAds.instance.initialize();
}
await Hive.initFlutter();
await Hive.openLazyBox<String>("gallery");
runApp(const MyApp());
}
@@ -22,24 +33,111 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ShadcnApp.router(
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => FormProvider()),
ChangeNotifierProvider(create: (_) => SessionProvider()),
ChangeNotifierProvider(create: (_) => RenderProvider()),
ChangeNotifierProvider(create: (_) => GalleryProvider()),
ChangeNotifierProvider(create: (_) => BackendStatusProvider()),
],
child: ShadcnApp.router(
title: 'TweetForge',
routerConfig: _router,
scaling: defaultTargetPlatform == TargetPlatform.android ? AdaptiveScaling.only(
sizeScaling: 1,
textScaling: 1.1,
radiusScaling: 1.1
) : null,
scaling: {
TargetPlatform.iOS: AdaptiveScaling.only(
sizeScaling: 1.3,
textScaling: 1.2,
radiusScaling: 1.3
),
TargetPlatform.android: AdaptiveScaling.only(
sizeScaling: 1.2,
textScaling: 1.02,
radiusScaling: 1.0
),
}[defaultTargetPlatform] ?? AdaptiveScaling.mobile,
theme: ThemeData(
colorScheme: ColorSchemes.darkBlue,
colorScheme: ColorSchemes.darkGray.indigo,
surfaceOpacity: 1,
),
);
));
}
}
GoRouter _router = GoRouter(
initialLocation: "/",
routes: [
HomePage.route
ShellRoute(
builder: (context, state, child) {
return _AppShell(child: child);
},
routes: [
HomePage.route,
GalleryPage.route,
]
),
],
);
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)
],
);
}
}
+78
View File
@@ -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>);
}
+238
View File
@@ -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("/");
}
}
}
+873 -846
View File
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();
}
}
+254
View File
@@ -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");
}
}
}
+92
View File
@@ -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!);
}
}
+240
View File
@@ -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();
}
}
+113
View File
@@ -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();
}
}
+39
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import "dart:async";
import "dart:convert";
import "dart:typed_data";
import "package:flutter/foundation.dart";
@@ -66,13 +67,44 @@ class QuoteSessionRequest {
});
// convert img bytes to base64 data uri
// convert img/video bytes to base64 data uri
String? _encodeImage(dynamic image) {
if (image == null) return null;
if (image is String) return image;
if (image is Uint8List) {
final base64String = base64Encode(image);
return "data:image/png;base64,$base64String";
// detect mime type from magic bytes
String mimeType = "image/png"; // default
if (image.length >= 4) {
// MP4: starts with ftyp box
if (image.length >= 12 &&
image[4] == 0x66 && image[5] == 0x74 &&
image[6] == 0x79 && image[7] == 0x70) {
mimeType = "video/mp4";
}
// WebM: starts with 0x1A45DFA3
else if (image[0] == 0x1A && image[1] == 0x45 &&
image[2] == 0xDF && image[3] == 0xA3) {
mimeType = "video/webm";
}
// GIF: starts with GIF87a or GIF89a
else if (image[0] == 0x47 && image[1] == 0x49 && image[2] == 0x46) {
mimeType = "image/gif";
}
// PNG: starts with 0x89504E47
else if (image[0] == 0x89 && image[1] == 0x50 &&
image[2] == 0x4E && image[3] == 0x47) {
mimeType = "image/png";
}
// JPEG: starts with 0xFFD8FF
else if (image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF) {
mimeType = "image/jpeg";
}
}
return "data:$mimeType;base64,$base64String";
}
return null;
}
@@ -123,6 +155,10 @@ class QuoteSession {
final int? timestamp;
final bool? verified;
final QuoteEngagement? engagement;
final String? renderStatus;
final int? renderProgress;
final int? renderPid;
final String? renderError;
final int createdAt;
final int updatedAt;
@@ -136,6 +172,10 @@ class QuoteSession {
this.timestamp,
this.verified,
this.engagement,
this.renderStatus,
this.renderProgress,
this.renderPid,
this.renderError,
required this.createdAt,
required this.updatedAt,
});
@@ -153,10 +193,23 @@ class QuoteSession {
engagement: json["engagement"] != null
? QuoteEngagement.fromJson(json["engagement"])
: null,
renderStatus: json["renderStatus"],
renderProgress: json["renderProgress"],
renderPid: json["renderPid"],
renderError: json["renderError"],
createdAt: json["createdAt"],
updatedAt: json["updatedAt"],
);
}
// helper to check if render is complete
bool get isRenderComplete => renderStatus == "completed";
// helper to check if render failed
bool get isRenderFailed => renderStatus == "failed";
// helper to check if render is in progress
bool get isRendering => renderStatus == "rendering" || renderStatus == "pending";
}
@@ -187,9 +240,72 @@ class QuoteSnapshot {
}
class RenderProgress {
final String status;
final String sessionId;
final int progress;
final String? stage;
final String? error;
RenderProgress({
required this.status,
required this.sessionId,
required this.progress,
this.stage,
this.error,
});
factory RenderProgress.fromJson(Map<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 {
// static const String _baseUrl = "https://quotes.imbenji.net";
static const String _baseUrl = kDebugMode ? "http://localhost:3000" : "https://quotes.imbenji.net";
static final String _baseUrl = kDebugMode && defaultTargetPlatform == TargetPlatform.iOS ? "http://localhost:3000" : "https://quotes.imbenji.net";
// create new session
@@ -255,8 +371,8 @@ class QuoteGeneratorApiV2 {
}
}
// render the session as image
static Future<Uint8List> generateImage(String sessionId) async {
// render the session as image or video (returns full response info)
static Future<MediaResponse> generateMedia(String sessionId) async {
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image");
final response = await http.get(
@@ -265,12 +381,23 @@ class QuoteGeneratorApiV2 {
);
if (response.statusCode == 200) {
return response.bodyBytes;
return MediaResponse(
bytes: response.bodyBytes,
contentType: response.headers["content-type"] ?? "image/png",
sessionId: response.headers["x-session-id"],
cacheHit: response.headers["x-cache"] == "HIT",
);
} else {
throw Exception("Failed to generate image: ${response.statusCode}");
throw Exception("Failed to generate media: ${response.statusCode}");
}
}
// render the session as image (legacy method, returns just bytes)
static Future<Uint8List> generateImage(String sessionId) async {
final media = await generateMedia(sessionId);
return media.bytes;
}
// delete session
static Future<void> deleteSession(String sessionId) async {
@@ -304,8 +431,8 @@ class QuoteGeneratorApiV2 {
}
}
// get snapshot image by token
static Future<Uint8List> getSnapshot(String token) async {
// get snapshot media by token (returns full response info)
static Future<MediaResponse> getSnapshotMedia(String token) async {
final url = Uri.parse("$_baseUrl/v2/snapshot/$token");
final response = await http.get(
@@ -314,12 +441,118 @@ class QuoteGeneratorApiV2 {
);
if (response.statusCode == 200) {
return response.bodyBytes;
return MediaResponse(
bytes: response.bodyBytes,
contentType: response.headers["content-type"] ?? "image/png",
sessionId: response.headers["x-snapshot-token"],
cacheHit: response.headers["x-cache"] == "HIT",
);
} else {
throw Exception("Failed to get snapshot: ${response.statusCode}");
}
}
// get snapshot image by token (legacy method, returns just bytes)
static Future<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
static int getCurrentTimestamp() {
+403
View File
@@ -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 share_plus
import shared_preferences_foundation
import video_player_avfoundation
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}
+13
View File
@@ -1,6 +1,8 @@
PODS:
- file_picker (0.0.1):
- FlutterMacOS
- file_saver (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
@@ -10,21 +12,28 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`)
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
EXTERNAL SOURCES:
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_saver:
:path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_foundation:
@@ -33,15 +42,19 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
video_player_avfoundation:
:path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin
webview_flutter_wkwebview:
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
SPEC CHECKSUMS:
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
+141 -16
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
animation_kit:
dependency: transitive
description:
name: animation_kit
sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63
url: "https://pub.dev"
source: hosted
version: "0.0.2"
archive:
dependency: transitive
description:
@@ -37,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
charcode:
dependency: transitive
description:
@@ -117,10 +125,10 @@ packages:
dependency: transitive
description:
name: data_widget
sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba"
sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
version: "0.0.3"
dio:
dependency: transitive
description:
@@ -214,6 +222,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -222,6 +235,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -256,6 +277,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.3.1"
hive:
dependency: "direct main"
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hive_flutter:
dependency: "direct main"
description:
name: hive_flutter
sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc
url: "https://pub.dev"
source: hosted
version: "1.1.0"
html:
dependency: transitive
description:
@@ -292,10 +329,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
jovial_misc:
dependency: transitive
description:
@@ -356,26 +393,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -384,6 +421,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
path:
dependency: transitive
description:
@@ -392,6 +437,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -480,6 +533,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.3"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
quiver:
dependency: transitive
description:
@@ -500,10 +561,10 @@ packages:
dependency: "direct main"
description:
name: shadcn_flutter
sha256: "1fd4f798c39d6308dc8f7e94d9e870b5db39fbf417ea95c423c7555ce8227a1c"
sha256: "3a5d92981a0504dd5e42b1f08e413233dbeaa5d075253bb500fe5e71bb01aadc"
url: "https://pub.dev"
source: hosted
version: "0.0.47"
version: "0.0.49"
share_plus:
dependency: "direct main"
description:
@@ -633,10 +694,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -701,6 +762,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
url: "https://pub.dev"
source: hosted
version: "1.1.20"
vector_math:
dependency: transitive
description:
@@ -709,6 +794,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
url: "https://pub.dev"
source: hosted
version: "2.9.1"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
url: "https://pub.dev"
source: hosted
version: "2.8.9"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
+9 -2
View File
@@ -30,7 +30,7 @@ environment:
dependencies:
flutter:
sdk: flutter
shadcn_flutter: ^0.0.47
shadcn_flutter: ^0.0.49
go_router: ^17.0.1
http: ^1.2.2
file_picker: ^8.1.6
@@ -38,8 +38,15 @@ dependencies:
share_plus: ^10.1.4
path_provider: ^2.1.5
shared_preferences: ^2.3.4
intl: ^0.19.0
intl: ^0.20.2
file_saver: ^0.2.14
video_player: ^2.9.2
flutter_svg: ^2.0.0
provider: ^6.1.5+1
hive: ^2.2.3
hive_flutter: ^1.1.0
# Ad integration
google_mobile_ads: ^5.2.0