This commit is contained in:
28
lib/main.dart
Normal file
28
lib/main.dart
Normal 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
348
lib/pages/home/page.dart
Normal 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
130
lib/services/quote_api.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user