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
@@ -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();
}
}