initial
Some checks failed
Build Android App / build (push) Failing after 34s

This commit is contained in:
ImBenji
2025-12-31 13:36:09 +00:00
commit fa45fb0a4f
135 changed files with 5934 additions and 0 deletions

28
lib/main.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:quotegen_client/pages/home/page.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ShadcnApp.router(
title: 'Flutter Demo',
routerConfig: _router,
);
}
}
GoRouter _router = GoRouter(
initialLocation: "/",
routes: [
HomePage.route
],
);

348
lib/pages/home/page.dart Normal file
View File

@@ -0,0 +1,348 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:image/image.dart' as img;
import 'package:quotegen_client/services/quote_api.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class HomePage extends StatefulWidget {
static GoRoute route = GoRoute(
path: "/",
builder: (context, state) => HomePage(),
);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
Map<String, dynamic> formData = {
"display_name": "",
"handle": "",
"content": "",
"timestamp": 0,
"avatar_image": null,
"post_image": null,
};
Uint8List? postPreviewImage;
@override
Widget build(BuildContext context) {
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
return Scaffold(
child: Column(
children: [
Gap(max(topPadding, 14)),
AspectRatio(
aspectRatio: 1,
child: OutlinedContainer(
backgroundColor: Colors.black,
child: postPreviewImage == null ? Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
) : Image.memory(
postPreviewImage!,
fit: BoxFit.cover,
),
)
).withPadding(
horizontal: 14
),
Gap(14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Display Name"
).xSmall.semiBold,
Gap(6),
TextField(
placeholder: Text(
"e.g. John Doe"
),
onChanged: (value) {
setState(() {
formData["display_name"] = value;
});
onSubmit();
},
),
],
),
),
Gap(14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Handle"
).xSmall.semiBold,
Gap(6),
TextField(
placeholder: Text(
"e.g. @johndoe"
),
onChanged: (value) {
setState(() {
formData["handle"] = value;
});
onSubmit();
},
),
],
),
),
],
),
Gap(10),
SizedBox(
height: 96,
child: Row(
children: [
Expanded(
child: Column(
children: [
SizedBox(
width: double.infinity,
child: OutlineButton(
child: Text("Select Avatar Image"),
onPressed: () async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result != null) {
Uint8List? imageBytes;
// on web, bytes is availble directly
if (result.files.single.bytes != null) {
imageBytes = result.files.single.bytes!;
}
// on mobile (iOS/Android), need to raed from path
else if (result.files.single.path != null) {
File file = File(result.files.single.path!);
imageBytes = await file.readAsBytes();
}
if (imageBytes != null) {
// compression disabled for testing
// Uint8List? compressedBytes = await compressImage(imageBytes, maxWidth: 400);
if (!mounted) return;
setState(() {
formData["avatar_image"] = imageBytes;
});
onSubmit();
}
}
},
),
),
Gap(6),
SizedBox(
width: double.infinity,
child: OutlineButton(
child: Text("Select Post Image"),
onPressed: () async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result != null) {
Uint8List? imageBytes;
// on web, bytes is availble directly
if (result.files.single.bytes != null) {
imageBytes = result.files.single.bytes!;
}
// on mobile (iOS/Android), need to raed from path
else if (result.files.single.path != null) {
File file = File(result.files.single.path!);
imageBytes = await file.readAsBytes();
}
if (imageBytes != null) {
if (!mounted) return;
setState(() {
formData["post_image"] = imageBytes;
});
onSubmit();
}
}
},
),
),
]
),
),
Gap(10),
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(1000),
child: formData["avatar_image"] != null ? Image.memory(
formData["avatar_image"],
width: 50,
height: 50,
fit: BoxFit.cover,
) : OutlinedContainer(
borderRadius: BorderRadius.circular(1000),
child: Center(
child: Text(
formData["display_name"].isNotEmpty ? formData["display_name"][0].toUpperCase() : "P",
)
),
),
)
),
],
),
),
Gap(10),
Text(
"Content"
).xSmall.semiBold,
Gap(6),
TextArea(
onChanged: (value) {
setState(() {
formData["content"] = value;
});
onSubmit();
},
),
Gap(10),
Text(
"Timestamp"
).xSmall.semiBold,
Gap(6),
SizedBox(
width: double.infinity,
child: DatePicker(
value: DateTime.now(),
onChanged: (DateTime? date) {
setState(() {
formData["timestamp"] = date!.millisecondsSinceEpoch;
});
onSubmit();
}
)
),
],
).withPadding(
horizontal: 14
)
],
),
);
}
// compress and resize image to reduce payload size
Future<Uint8List?> compressImage(Uint8List bytes, {int maxWidth = 800, int quality = 85}) async {
try {
img.Image? image = img.decodeImage(bytes);
if (image == null) return null;
// resize if image is larger than maxWidth
if (image.width > maxWidth) {
image = img.copyResize(image, width: maxWidth);
}
// encode as JPEG with compression
return Uint8List.fromList(img.encodeJpg(image, quality: quality));
} catch (e) {
print("Error compresing image: $e");
return null;
}
}
int _submitCount = 0;
void onSubmit() async {
int currentSubmit = ++_submitCount;
await Future.delayed(Duration(milliseconds: 700));
// Only proceed if this is the latest submit
if (currentSubmit != _submitCount) return;
if (!mounted) return;
setState(() {
postPreviewImage = null;
});
print("Form Data Submitted: $formData");
if (!formData["handle"].startsWith("@")) {
formData["handle"] = "@" + formData["handle"];
}
QuoteRequest request = QuoteRequest(
displayName: formData["display_name"],
username: formData["handle"].toLowerCase(),
text: formData["content"],
timestamp: formData["timestamp"],
avatarUrl: formData["avatar_image"],
imageUrl: formData["post_image"],
);
Uint8List imageBytes = await QuoteGeneratorApi.generateQuotePost(request);
if (!mounted) return;
setState(() {
postPreviewImage = imageBytes;
});
}
}

130
lib/services/quote_api.dart Normal file
View File

@@ -0,0 +1,130 @@
import "dart:convert";
import "dart:typed_data";
import "package:http/http.dart" as http;
class QuoteRequest {
final String? displayName;
final String? username;
final dynamic avatarUrl; // can be String or Uint8List
final String? text;
final dynamic imageUrl; // can be String or Uint8List
final int? timestamp;
QuoteRequest({
this.displayName,
this.username,
this.avatarUrl,
this.text,
this.imageUrl,
this.timestamp,
});
// convert image 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";
}
return null;
}
Map<String, dynamic> toJson() {
return {
"displayName": displayName,
"username": username,
"avatarUrl": _encodeImage(avatarUrl),
"text": text,
"imageUrl": _encodeImage(imageUrl),
"timestamp": timestamp,
};
}
String toQueryString() {
final params = <String, String>{};
if (displayName != null) params["displayName"] = displayName!;
if (username != null) params["username"] = username!;
final encodedAvatar = _encodeImage(avatarUrl);
if (encodedAvatar != null) params["avatarUrl"] = encodedAvatar;
if (text != null) params["text"] = text!;
final encodedImage = _encodeImage(imageUrl);
if (encodedImage != null) params["imageUrl"] = encodedImage;
if (timestamp != null) params["timestamp"] = timestamp.toString();
return Uri(queryParameters: params).query;
}
}
class QuoteGeneratorApi {
static const String _baseUrl = "https://quotes.imbenji.net";
// static const String _baseUrl = "http://localhost:3000";
// genrate a quote image using POST
static Future<Uint8List> generateQuotePost(QuoteRequest request) async {
final url = Uri.parse("$_baseUrl/generate");
final requestBody = request.toJson();
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode(requestBody),
);
if (response.statusCode == 200) {
return response.bodyBytes;
} else {
throw Exception("Failed to genrate quote: ${response.statusCode}");
}
}
// generate a quote image usng GET
static Future<Uint8List> generateQuoteGet(QuoteRequest request) async {
final queryString = request.toQueryString();
final url = Uri.parse("$_baseUrl/generate?$queryString");
final response = await http.get(url);
if (response.statusCode == 200) {
return response.bodyBytes;
} else {
throw Exception("Failed to generate quote: ${response.statusCode}");
}
}
// convienence method that uses POST by defualt
static Future<Uint8List> generateQuote(QuoteRequest request) async {
return generateQuotePost(request);
}
// check api helth
static Future<bool> checkHealth() async {
try {
final url = Uri.parse("$_baseUrl/health");
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data["status"] == "ok";
}
return false;
} catch (e) {
return false;
}
}
// helper to get current timestmp in seconds
static int getCurrentTimestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
}