This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:quotegen_client/pages/home/page.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@@ -16,6 +16,14 @@ class MyApp extends StatelessWidget {
|
||||
return ShadcnApp.router(
|
||||
title: 'Flutter Demo',
|
||||
routerConfig: _router,
|
||||
scaling: defaultTargetPlatform == TargetPlatform.android ? AdaptiveScaling.only(
|
||||
sizeScaling: 1,
|
||||
textScaling: 1.1,
|
||||
radiusScaling: 1.1
|
||||
) : null,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkBlue,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
@@ -9,6 +10,10 @@ 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 {
|
||||
|
||||
@@ -31,11 +36,29 @@ class _HomePageState extends State<HomePage> {
|
||||
"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) {
|
||||
|
||||
@@ -43,252 +66,345 @@ class _HomePageState extends State<HomePage> {
|
||||
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,
|
||||
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
|
||||
)
|
||||
],
|
||||
),
|
||||
) : 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(
|
||||
)
|
||||
).withPadding(
|
||||
horizontal: 14
|
||||
),
|
||||
|
||||
Gap(14),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Display Name"
|
||||
).xSmall.semiBold,
|
||||
|
||||
Gap(6),
|
||||
|
||||
TextField(
|
||||
controller: _displayNameController,
|
||||
placeholder: Text(
|
||||
"e.g. John Doe"
|
||||
),
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
formData["display_name"] = value;
|
||||
});
|
||||
_saveFormData();
|
||||
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(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);
|
||||
|
||||
Gap(10),
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
formData["avatar_image"] = resizedBytes ?? imageBytes;
|
||||
});
|
||||
_saveFormData();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
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["timestamp"] = date!.millisecondsSinceEpoch;
|
||||
formData["content"] = value;
|
||||
});
|
||||
_saveFormData();
|
||||
onSubmit();
|
||||
}
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
Gap(10),
|
||||
|
||||
],
|
||||
).withPadding(
|
||||
horizontal: 14
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// compress and resize image to reduce payload size
|
||||
// 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);
|
||||
@@ -299,10 +415,10 @@ class _HomePageState extends State<HomePage> {
|
||||
image = img.copyResize(image, width: maxWidth);
|
||||
}
|
||||
|
||||
// encode as JPEG with compression
|
||||
return Uint8List.fromList(img.encodeJpg(image, quality: quality));
|
||||
// encode as PNG (lossles, no compression)
|
||||
return Uint8List.fromList(img.encodePng(image));
|
||||
} catch (e) {
|
||||
print("Error compresing image: $e");
|
||||
print("Error resizng image: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -345,4 +461,104 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user