Files
Quote-Generator-Client/lib/pages/home/page.dart
ImBenji 02d9d82575
Some checks failed
Build Android App / build (push) Failing after 2m15s
initial
2026-01-01 07:45:17 +00:00

564 lines
18 KiB
Dart

import 'dart:convert';
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';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:shared_preferences/shared_preferences.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;
final TextEditingController _displayNameController = TextEditingController();
final TextEditingController _handleController = TextEditingController();
final TextEditingController _contentController = TextEditingController();
@override
void initState() {
super.initState();
_loadFormData();
}
@override
void dispose() {
_displayNameController.dispose();
_handleController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
return Scaffold(
child: SingleChildScrollView(
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,
),
) : Stack(
children: [
Image.memory(
postPreviewImage!,
fit: BoxFit.cover,
),
Align(
alignment: Alignment.bottomRight,
child: IconButton.secondary(
icon: Icon(
Icons.share,
),
onPressed: () async {
await shareImage();
}
)
).withPadding(
all: 12
)
],
),
)
).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(
controller: _displayNameController,
placeholder: Text(
"e.g. John Doe"
),
onChanged: (value) {
setState(() {
formData["display_name"] = value;
});
_saveFormData();
onSubmit();
},
),
],
),
),
Gap(14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Handle"
).xSmall.semiBold,
Gap(6),
TextField(
controller: _handleController,
placeholder: Text(
"e.g. @johndoe"
),
onChanged: (value) {
setState(() {
formData["handle"] = value;
});
_saveFormData();
onSubmit();
},
),
],
),
),
],
),
Gap(10),
SizedBox(
height: 132,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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) {
// downscale avatar image
Uint8List? resizedBytes = await compressImage(imageBytes, maxWidth: 400);
if (!mounted) return;
setState(() {
formData["avatar_image"] = resizedBytes ?? imageBytes;
});
_saveFormData();
onSubmit();
}
}
},
),
),
Gap(8),
Divider(),
Gap(6),
Text(
"Timestamp"
).xSmall.semiBold,
Gap(6),
SizedBox(
width: double.infinity,
child: DatePicker(
value: formData["timestamp"] > 0
? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"])
: DateTime.now(),
onChanged: (DateTime? date) {
setState(() {
formData["timestamp"] = date!.millisecondsSinceEpoch;
});
_saveFormData();
onSubmit();
}
)
).withPadding(
right: 10
),
]
),
),
// Gap(10),
VerticalDivider().withPadding(
top: 54
),
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(
controller: _contentController,
onChanged: (value) {
setState(() {
formData["content"] = value;
});
_saveFormData();
onSubmit();
},
),
Gap(10),
Divider(),
Gap(10),
if (formData["post_image"] != null)...[
OutlinedContainer(
child: formData["post_image"] != null ? Image.memory(
formData["post_image"],
width: double.infinity,
// height: 200,
fit: BoxFit.cover,
) : Center(
child: Text(
"No Post Image Selected",
).small.semiBold,
),
),
Gap(10),
],
Row(
children: [
Expanded(
child: SizedBox(
width: double.infinity,
child: OutlineButton(
child: formData["post_image"] == null ? Text("Select Post Image") : Text("Change 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;
});
_saveFormData();
onSubmit();
}
}
},
),
),
),
if (formData["post_image"] != null)...[
Gap(10),
IconButton.destructive(
icon: Icon(
Icons.delete,
),
onPressed: () {
if (!mounted) return;
setState(() {
formData["post_image"] = null;
});
_saveFormData();
onSubmit();
},
)
]
],
),
Gap(bottomPadding)
],
).withPadding(
horizontal: 14
)
],
),
),
);
}
// resize image to reduce payload size without comression
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 PNG (lossles, no compression)
return Uint8List.fromList(img.encodePng(image));
} catch (e) {
print("Error resizng 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;
});
}
Future<void> shareImage() async {
if (postPreviewImage == null) {
print("No image to share");
return;
}
try {
// get temporary directry
final tempDir = await getTemporaryDirectory();
// create a unique filename with timestmp
final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}.png";
final filePath = path.join(tempDir.path, fileName);
// write the image bytes to a temporry file
final file = File(filePath);
await file.writeAsBytes(postPreviewImage!);
// Share the file using the systm share dialog
final result = await Share.shareXFiles(
[XFile(filePath)],
text: "Check out this quote!",
);
// optinal: log share result
if (result.status == ShareResultStatus.success) {
print("Share succesful");
}
} catch (e) {
print("Error sharin image: $e");
}
}
// load form data from shared preferences
Future<void> _loadFormData() async {
try {
final prefs = await SharedPreferences.getInstance();
setState(() {
// load text fields
formData["display_name"] = prefs.getString("display_name") ?? "";
formData["handle"] = prefs.getString("handle") ?? "";
formData["content"] = prefs.getString("content") ?? "";
formData["timestamp"] = prefs.getInt("timestamp") ?? 0;
// load images from base64
String? avatarBase64 = prefs.getString("avatar_image");
if (avatarBase64 != null && avatarBase64.isNotEmpty) {
formData["avatar_image"] = base64Decode(avatarBase64);
}
String? postBase64 = prefs.getString("post_image");
if (postBase64 != null && postBase64.isNotEmpty) {
formData["post_image"] = base64Decode(postBase64);
}
// update controllers
_displayNameController.text = formData["display_name"];
_handleController.text = formData["handle"];
_contentController.text = formData["content"];
});
// regenerate preview if we have data
if (formData["display_name"].isNotEmpty || formData["content"].isNotEmpty) {
onSubmit();
}
} catch (e) {
print("Error loading form data: $e");
}
}
// save form data to shared preferences
Future<void> _saveFormData() async {
try {
final prefs = await SharedPreferences.getInstance();
// save text felds
await prefs.setString("display_name", formData["display_name"]);
await prefs.setString("handle", formData["handle"]);
await prefs.setString("content", formData["content"]);
await prefs.setInt("timestamp", formData["timestamp"]);
// save images as base64
if (formData["avatar_image"] != null) {
String avatarBase64 = base64Encode(formData["avatar_image"]);
await prefs.setString("avatar_image", avatarBase64);
}
if (formData["post_image"] != null) {
String postBase64 = base64Encode(formData["post_image"]);
await prefs.setString("post_image", postBase64);
}
} catch (e) {
print("Error savin form data: $e");
}
}
}