add macOS support for ad loading and improve timestamp handling
Some checks failed
Build Android App / build (push) Failing after 49s

This commit is contained in:
ImBenji
2026-01-02 18:46:12 +00:00
parent 016f7911f3
commit 7d8e19d1f5
13 changed files with 369 additions and 75 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:quotegen_client/pages/home/page.dart';
@@ -7,8 +8,8 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize mobile ads (AdMob) only on mobile platforms
if (!kIsWeb) {
// Initialize mobile ads (AdMob) only on mobile platforms (not web or macOS)
if (!kIsWeb && !Platform.isMacOS) {
await MobileAds.instance.initialize();
}

View File

@@ -6,6 +6,7 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:image/image.dart' as img;
@@ -17,6 +18,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
import 'package:file_saver/file_saver.dart';
// number formatter for engagement fields
class NumberTextInputFormatter extends TextInputFormatter {
@@ -68,7 +70,8 @@ class _HomePageState extends State<HomePage> {
"display_name": "",
"handle": "",
"content": "",
"timestamp": 0,
"date": DateTime.now(),
"time": TimeOfDay.now(),
"avatar_image": null,
"post_image": null,
"replies": "",
@@ -134,6 +137,9 @@ class _HomePageState extends State<HomePage> {
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
// Check if we're on mobile (iOS/Android)
bool isMobilePlatform = !kIsWeb && (Platform.isIOS || Platform.isAndroid);
if (isWideLayout) {
return Scaffold(
child: Center(
@@ -142,15 +148,11 @@ class _HomePageState extends State<HomePage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Gap(max(topPadding, 14)),
SizedBox(
height: 600,
child: _buildPreview(context)
),
Gap(14),
ConstrainedBox(
@@ -166,8 +168,6 @@ class _HomePageState extends State<HomePage> {
],
),
),
Gap(max(bottomPadding, 14)),
],
),
),
@@ -175,28 +175,55 @@ class _HomePageState extends State<HomePage> {
);
}
return Scaffold(
child: SingleChildScrollView(
child: Column(
children: [
// Mobile layout content
Widget mainContent = SingleChildScrollView(
child: Column(
children: [
Gap(max(topPadding, 14)),
Gap(max(topPadding, 14)),
_buildPreview(context),
_buildPreview(context),
Gap(14),
Gap(14),
_buildForm(context),
_buildForm(context),
Gap(14),
Gap(14),
// Banner ad
// Add bottom padding on mobile to avoid content being hidden behind ad
if (isMobilePlatform)
Gap(100), // Space for the overlay ad (80px min height + padding)
// Only show inline ad on non-mobile platforms
if (!isMobilePlatform)
AdBanner(),
Gap(max(bottomPadding, 14)),
Gap(max(bottomPadding, 14)),
],
),
);
// Wrap in Stack with overlay ad for mobile platforms
if (isMobilePlatform) {
return Scaffold(
child: Stack(
children: [
mainContent,
// Bottom overlay ad for mobile
Positioned(
left: 0,
right: 0,
bottom: max(bottomPadding, 0),
child: AdBanner(),
),
],
),
),
);
}
return Scaffold(
child: mainContent,
);
}
@@ -218,15 +245,110 @@ class _HomePageState extends State<HomePage> {
),
Align(
alignment: Alignment.topRight,
child: IconButton.secondary(
alignment: Alignment.topRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ([TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.macOS].contains(defaultTargetPlatform))...[
IconButton.secondary(
icon: Icon(
Icons.share,
),
onPressed: () async {
await shareImage();
}
),
Gap(8),
],
IconButton.secondary(
icon: Icon(
LucideIcons.download,
),
onPressed: () async {
if (postPreviewImage == null) {
print("No image avalable to download");
return;
}
try {
// genrate filename with timestmp
final fileName = "quote_${DateTime.now().millisecondsSinceEpoch}";
await FileSaver.instance.saveFile(
name: fileName,
bytes: postPreviewImage!,
ext: "png",
mimeType: MimeType.png,
);
print("Image downloaded succesfully");
} catch (e) {
print("Error downloadin image: $e");
}
}
),
Gap(8),
IconButton.outline(
icon: Icon(
Icons.share,
LucideIcons.link
),
onPressed: () async {
await shareImage();
}
)
if (_currentSessionId == null) {
print("No session available");
return;
}
try {
// create snapshot link
QuoteSnapshot snapshot = await QuoteGeneratorApiV2.createSnapshotLink(_currentSessionId!);
// copy URL to clipboard
await Clipboard.setData(ClipboardData(text: snapshot.url));
if (!mounted) return;
// show toast notification
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
title: const Text('Snapshot Link Copied'),
subtitle: const Text('The snapshot link has been copied to clipboard'),
trailingAlignment: Alignment.center,
),
);
}
);
print("Snapshot link created: ${snapshot.url}");
} catch (e) {
print("Error creating snapshot link: $e");
if (!mounted) return;
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
title: const Text('Error'),
subtitle: const Text('Failed to create snapshot link'),
trailingAlignment: Alignment.center,
),
);
}
);
}
},
)
].reversed.toList(),
)
).withPadding(
all: 12
)
@@ -258,6 +380,13 @@ class _HomePageState extends State<HomePage> {
placeholder: Text(
"Display Name"
),
features: [
InputFeature.leading(
Icon(
LucideIcons.user
),
)
],
initialValue: formData["display_name"],
onChanged: (value) {
setState(() {
@@ -295,6 +424,13 @@ class _HomePageState extends State<HomePage> {
placeholder: Text(
"Handle"
),
features: [
InputFeature.leading(
Icon(
LucideIcons.atSign
)
)
],
initialValue: formData["handle"],
onChanged: (value) {
setState(() {
@@ -413,44 +549,46 @@ class _HomePageState extends State<HomePage> {
children: [
Expanded(
child: DatePicker(
value: formData["timestamp"] > 0
? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"])
: DateTime.now(),
value: formData["date"],
onChanged: (DateTime? date) {
if (date != null) {
setState(() {
formData["timestamp"] = date.millisecondsSinceEpoch;
formData["date"] = date;
});
_saveFormData();
onSubmit(changedField: "timestamp");
onSubmit(changedField: "datetime");
}
},
),
),
TimePicker(
value: formData["timestamp"] > 0
? TimeOfDay.fromDateTime(DateTime.fromMillisecondsSinceEpoch(formData["timestamp"]))
: TimeOfDay.now(),
value: formData["time"],
onChanged: (TimeOfDay? time) {
if (time != null) {
DateTime currentDate = formData["timestamp"] > 0
? DateTime.fromMillisecondsSinceEpoch(formData["timestamp"])
: DateTime.now();
DateTime newDateTime = DateTime(
currentDate.year,
currentDate.month,
currentDate.day,
time.hour,
time.minute,
);
setState(() {
formData["timestamp"] = newDateTime.millisecondsSinceEpoch;
formData["time"] = time;
});
_saveFormData();
onSubmit(changedField: "timestamp");
onSubmit(changedField: "datetime");
}
},
),
Button.secondary(
child: Text(
"Now"
),
onPressed: () {
DateTime now = DateTime.now();
TimeOfDay nowTime = TimeOfDay.now();
setState(() {
formData["date"] = now;
formData["time"] = nowTime;
});
_saveFormData();
onSubmit(changedField: "datetime");
},
)
]
@@ -786,6 +924,22 @@ class _HomePageState extends State<HomePage> {
int _submitCount = 0;
String? _currentSessionId;
// combine date and time into unix timestamp (in seconds for API)
int _combineDateTime() {
DateTime date = formData["date"];
TimeOfDay time = formData["time"];
DateTime combined = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
return combined.millisecondsSinceEpoch ~/ 1000;
}
void onSubmit({String? changedField}) async {
int currentSubmit = ++_submitCount;
@@ -802,10 +956,6 @@ class _HomePageState extends State<HomePage> {
print("Form Data Submitted: $formData");
if (!formData["handle"].startsWith("@")) {
formData["handle"] = "@" + formData["handle"];
}
// Wait for session to be ready
while (_currentSessionId == null) {
await Future.delayed(Duration(milliseconds: 100));
@@ -831,7 +981,7 @@ class _HomePageState extends State<HomePage> {
displayName: formData["display_name"],
username: formData["handle"].toLowerCase(),
text: formData["content"],
timestamp: formData["timestamp"],
timestamp: _combineDateTime(),
avatarUrl: formData["avatar_image"],
imageUrl: formData["post_image"],
verified: formData["verified"] ?? false,
@@ -846,7 +996,7 @@ class _HomePageState extends State<HomePage> {
displayName: changedField == "display_name" ? formData["display_name"] : null,
username: changedField == "handle" ? formData["handle"].toLowerCase() : null,
text: changedField == "content" ? formData["content"] : null,
timestamp: changedField == "timestamp" ? formData["timestamp"] : null,
timestamp: changedField == "datetime" ? _combineDateTime() : null,
avatarUrl: changedField == "avatar_image" ? formData["avatar_image"] : null,
imageUrl: changedField == "post_image" ? formData["post_image"] : null,
verified: changedField == "verified" ? (formData["verified"] ?? false) : null,
@@ -1002,13 +1152,19 @@ class _HomePageState extends State<HomePage> {
postImage = base64Decode(postBase64);
}
// split timestamp into seperate date and time
DateTime dateTime = timestamp > 0
? DateTime.fromMillisecondsSinceEpoch(timestamp)
: DateTime.now();
print("Loaded form data: name=$displayName, handle=$handle, content=$content");
setState(() {
formData["display_name"] = displayName;
formData["handle"] = handle;
formData["content"] = content;
formData["timestamp"] = timestamp;
formData["date"] = DateTime(dateTime.year, dateTime.month, dateTime.day);
formData["time"] = TimeOfDay(hour: dateTime.hour, minute: dateTime.minute);
formData["verified"] = verified;
formData["replies"] = replies;
formData["retweets"] = retweets;
@@ -1035,17 +1191,13 @@ class _HomePageState extends State<HomePage> {
);
}
// fix handle format
if (handle.isNotEmpty && !handle.startsWith("@")) {
handle = "@$handle";
}
// create session with full form data
// create session with full form data (API expects timestamp in seconds)
// Use _combineDateTime() to get timestamp from formData (defaults to now if nothing saved)
QuoteSessionRequest initialRequest = QuoteSessionRequest(
displayName: displayName,
username: handle.toLowerCase(),
text: content,
timestamp: timestamp,
timestamp: _combineDateTime(),
verified: verified,
avatarUrl: avatarImage,
imageUrl: postImage,
@@ -1054,8 +1206,8 @@ class _HomePageState extends State<HomePage> {
await _initializeSession(initialRequest);
// genrate preview image if we have data
if (_currentSessionId != null && (displayName.isNotEmpty || content.isNotEmpty)) {
// genrate preview image if we have session
if (_currentSessionId != null) {
Uint8List imageBytes = await QuoteGeneratorApiV2.generateImage(_currentSessionId!);
if (!mounted) return;
setState(() {
@@ -1080,11 +1232,14 @@ class _HomePageState extends State<HomePage> {
print("Saving form data: name=${formData["display_name"]}, handle=${formData["handle"]}, content=${formData["content"]}");
// combine date and time into timestamp (in milliseconds for storage)
int timestampMillis = _combineDateTime() * 1000;
// 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"]);
await prefs.setInt("timestamp", timestampMillis);
await prefs.setString("replies", formData["replies"]);
await prefs.setString("retweets", formData["retweets"]);
await prefs.setString("likes", formData["likes"]);

View File

@@ -13,13 +13,13 @@ class AdSenseService implements AdService {
Future<void> initialize() async {
// AdSense is loaded via script tag in index.html
print("AdSense initialized (web)");
_isLoaded = true;
_isLoaded = false;
}
@override
Future<void> loadBannerAd() async {
// AdSense ads are loaded automatically when widget renders
_isLoaded = true;
_isLoaded = false;
}
@override

View File

@@ -159,9 +159,36 @@ class QuoteSession {
}
class QuoteSnapshot {
final String token;
final String url;
final String sessionId;
final int createdAt;
final int expiresAt;
QuoteSnapshot({
required this.token,
required this.url,
required this.sessionId,
required this.createdAt,
required this.expiresAt,
});
factory QuoteSnapshot.fromJson(Map<String, dynamic> json) {
return QuoteSnapshot(
token: json["token"],
url: json["url"],
sessionId: json["sessionId"],
createdAt: json["createdAt"],
expiresAt: json["expiresAt"],
);
}
}
class QuoteGeneratorApiV2 {
static const String _baseUrl = "https://quotes.imbenji.net";
// static const String _baseUrl = "http://localhost:3000";
// static const String _baseUrl = "https://quotes.imbenji.net";
static const String _baseUrl = "http://localhost:3000";
// create new session
@@ -259,6 +286,40 @@ class QuoteGeneratorApiV2 {
}
// create persistant snapshot link
static Future<QuoteSnapshot> createSnapshotLink(String sessionId) async {
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/snapshot-link");
final response = await http.post(
url,
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body);
return QuoteSnapshot.fromJson(data);
} else {
throw Exception("Failed to create snapshot link: ${response.statusCode}");
}
}
// get snapshot image by token
static Future<Uint8List> getSnapshot(String token) async {
final url = Uri.parse("$_baseUrl/v2/snapshot/$token");
final response = await http.get(
url,
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
);
if (response.statusCode == 200) {
return response.bodyBytes;
} else {
throw Exception("Failed to get snapshot: ${response.statusCode}");
}
}
// helper to get current timestamp in seconds
static int getCurrentTimestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;

View File

@@ -1,3 +1,4 @@
import "dart:io" show Platform;
import "package:flutter/foundation.dart" show kIsWeb;
import "package:flutter/widgets.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
@@ -15,11 +16,19 @@ class AdBanner extends StatefulWidget {
class _AdBannerState extends State<AdBanner> {
late AdService _adService;
bool _isLoading = true;
bool _isMacOS = false;
@override
void initState() {
super.initState();
_initializeAds();
// check if running on macOS
if (!kIsWeb && Platform.isMacOS) {
_isMacOS = true;
_isLoading = false;
} else {
_initializeAds();
}
}
Future<void> _initializeAds() async {
@@ -46,12 +55,42 @@ class _AdBannerState extends State<AdBanner> {
@override
void dispose() {
_adService.dispose();
if (!_isMacOS) {
_adService.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
// Check if we're on mobile (iOS/Android)
bool isMobile = !kIsWeb && !_isMacOS && (Platform.isIOS || Platform.isAndroid);
// Show placeholder only on macOS and web when ads fail to load
// On mobile, we want real ads or nothing
if (_isMacOS || (!isMobile && !_isLoading && !_adService.isLoaded)) {
return ConstrainedBox(
constraints: const BoxConstraints(
minHeight: 80,
),
child: OutlinedContainer(
child: Center(
child: Text(
"ADVERTISEMENT SPACE",
style: TextStyle(
color: Theme.of(context).colorScheme.mutedForeground,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
),
).withPadding(
horizontal: 8
);
}
if (_isLoading) {
return SizedBox(
height: 50,
@@ -65,11 +104,6 @@ class _AdBannerState extends State<AdBanner> {
);
}
if (!_adService.isLoaded) {
// Ad failed to load, show nothing
return SizedBox.shrink();
}
return _adService.getBannerWidget();
}
}