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
+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() {