diff --git a/lib/main.dart b/lib/main.dart index 1a140a5..a11bee4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); } diff --git a/lib/pages/home/page.dart b/lib/pages/home/page.dart index 7d33e1e..8f23bf3 100644 --- a/lib/pages/home/page.dart +++ b/lib/pages/home/page.dart @@ -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 { "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 { 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 { 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 { ], ), ), - - Gap(max(bottomPadding, 14)), ], ), ), @@ -175,28 +175,55 @@ class _HomePageState extends State { ); } - 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 { ), 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 { 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 { placeholder: Text( "Handle" ), + features: [ + InputFeature.leading( + Icon( + LucideIcons.atSign + ) + ) + ], initialValue: formData["handle"], onChanged: (value) { setState(() { @@ -413,44 +549,46 @@ class _HomePageState extends State { 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 { 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 { 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 { 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 { 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 { 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 { ); } - // 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 { 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 { 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"]); diff --git a/lib/services/adsense_service_web.dart b/lib/services/adsense_service_web.dart index 806c378..907f4c4 100644 --- a/lib/services/adsense_service_web.dart +++ b/lib/services/adsense_service_web.dart @@ -13,13 +13,13 @@ class AdSenseService implements AdService { Future initialize() async { // AdSense is loaded via script tag in index.html print("AdSense initialized (web)"); - _isLoaded = true; + _isLoaded = false; } @override Future loadBannerAd() async { // AdSense ads are loaded automatically when widget renders - _isLoaded = true; + _isLoaded = false; } @override diff --git a/lib/services/quote_api_v2.dart b/lib/services/quote_api_v2.dart index f38ae8b..bfe4bf2 100644 --- a/lib/services/quote_api_v2.dart +++ b/lib/services/quote_api_v2.dart @@ -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 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 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 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; diff --git a/lib/widgets/ad_banner.dart b/lib/widgets/ad_banner.dart index 80b499e..b3721bf 100644 --- a/lib/widgets/ad_banner.dart +++ b/lib/widgets/ad_banner.dart @@ -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 { 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 _initializeAds() async { @@ -46,12 +55,42 @@ class _AdBannerState extends State { @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 { ); } - if (!_adService.isLoaded) { - // Ad failed to load, show nothing - return SizedBox.shrink(); - } - return _adService.getBannerWidget(); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..464061c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..4c6b412 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 15f40e0..cd13034 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import file_picker +import file_saver import path_provider_foundation import share_plus import shared_preferences_foundation @@ -13,6 +14,7 @@ import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 451656f..df166f2 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -10,6 +10,9 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) @@ -17,6 +20,7 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: file_picker: @@ -29,6 +33,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af @@ -36,6 +42,7 @@ SPEC CHECKSUMS: path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba share_plus: 1fa619de8392a4398bfaf176d441853922614e89 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index 384062c..ece0df5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + dio: + dependency: transitive + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" email_validator: dependency: transitive description: @@ -169,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.7" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e" + url: "https://pub.dev" + source: hosted + version: "0.2.14" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f911435..63fde08 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: path_provider: ^2.1.5 shared_preferences: ^2.3.4 intl: ^0.19.0 + file_saver: ^0.2.14 # Ad integration google_mobile_ads: ^5.2.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c3384ec..ffc4d63 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 01d3836..822d779 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver share_plus url_launcher_windows )