add ad services and Docker configuration for web app
Some checks failed
Build Android App / build (push) Has been cancelled
Some checks failed
Build Android App / build (push) Has been cancelled
This commit is contained in:
46
.dockerignore
Normal file
46
.dockerignore
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Build outputs
|
||||||
|
build/
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
test/
|
||||||
|
.test_coverage/
|
||||||
|
|
||||||
|
# Android/iOS (not needed for web)
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
macos/
|
||||||
|
windows/
|
||||||
|
linux/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
*.swp
|
||||||
|
.env
|
||||||
@@ -18,6 +18,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ANDROID_HOME: /root/android-sdk
|
ANDROID_HOME: /root/android-sdk
|
||||||
ANDROID_SDK_ROOT: /root/android-sdk
|
ANDROID_SDK_ROOT: /root/android-sdk
|
||||||
|
GRADLE_OPTS: -Xmx2g -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Stage 1: Build the Flutter web app
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy pubspec files and get dependencies
|
||||||
|
COPY pubspec.yaml pubspec.lock ./
|
||||||
|
RUN flutter pub get
|
||||||
|
|
||||||
|
# Copy the rest of the app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build web app
|
||||||
|
RUN flutter build web --release --web-renderer html
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the built web app from build stage
|
||||||
|
COPY --from=build /app/build/web /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
android:label="quotegen_client"
|
android:label="quotegen_client"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<!-- AdMob App ID -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="ca-app-pub-5177609929140951~7846774739"/>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
quotegen-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: quotegen_web
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
@@ -34,6 +34,13 @@ PODS:
|
|||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- Google-Mobile-Ads-SDK (11.13.0):
|
||||||
|
- GoogleUserMessagingPlatform (>= 1.1)
|
||||||
|
- google_mobile_ads (5.3.1):
|
||||||
|
- Flutter
|
||||||
|
- Google-Mobile-Ads-SDK (~> 11.13.0)
|
||||||
|
- webview_flutter_wkwebview
|
||||||
|
- GoogleUserMessagingPlatform (3.1.0)
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -46,18 +53,25 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- SwiftyGif (5.4.5)
|
- SwiftyGif (5.4.5)
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
|
- Google-Mobile-Ads-SDK
|
||||||
|
- GoogleUserMessagingPlatform
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
@@ -66,23 +80,31 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
google_mobile_ads:
|
||||||
|
:path: ".symlinks/plugins/google_mobile_ads/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
Google-Mobile-Ads-SDK: 14f57f2dc33532a24db288897e26494640810407
|
||||||
|
google_mobile_ads: fe0e2c1764ad95323dd0e3081d0bb2d58411f957
|
||||||
|
GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
|
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
202FD1FBBA50227619E09BFC /* [CP] Embed Pods Frameworks */,
|
202FD1FBBA50227619E09BFC /* [CP] Embed Pods Frameworks */,
|
||||||
|
9EB32ED1B9C2EE1090869288 /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -340,6 +341,23 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
|
9EB32ED1B9C2EE1090869288 /* [CP] Copy Pods Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
F9A7E4B35A518B03A03269AF /* [CP] Check Pods Manifest.lock */ = {
|
F9A7E4B35A518B03A03269AF /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
|||||||
@@ -45,5 +45,14 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>ca-app-pub-5177609929140951~5220611390</string>
|
||||||
|
<key>SKAdNetworkItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>SKAdNetworkIdentifier</key>
|
||||||
|
<string>cstr6suwn9.skadnetwork</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:quotegen_client/pages/home/page.dart';
|
import 'package:quotegen_client/pages/home/page.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize mobile ads (AdMob) only on mobile platforms
|
||||||
|
if (!kIsWeb) {
|
||||||
|
await MobileAds.instance.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:quotegen_client/services/quote_api_v2.dart';
|
import 'package:quotegen_client/services/quote_api_v2.dart';
|
||||||
|
import 'package:quotegen_client/widgets/ad_banner.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -149,13 +150,21 @@ class _HomePageState extends State<HomePage> {
|
|||||||
child: _buildPreview(context)
|
child: _buildPreview(context)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
Gap(14),
|
Gap(14),
|
||||||
|
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
),
|
),
|
||||||
child: _buildForm(context)
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildForm(context)),
|
||||||
|
Gap(14),
|
||||||
|
AdBanner(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Gap(max(bottomPadding, 14)),
|
Gap(max(bottomPadding, 14)),
|
||||||
@@ -179,6 +188,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
_buildForm(context),
|
_buildForm(context),
|
||||||
|
|
||||||
|
Gap(14),
|
||||||
|
|
||||||
|
// Banner ad
|
||||||
|
AdBanner(),
|
||||||
|
|
||||||
Gap(max(bottomPadding, 14)),
|
Gap(max(bottomPadding, 14)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
19
lib/services/ad_service.dart
Normal file
19
lib/services/ad_service.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
|
||||||
|
abstract class AdService {
|
||||||
|
|
||||||
|
/// Initialize ad SDK
|
||||||
|
Future<void> initialize();
|
||||||
|
|
||||||
|
/// Load banner ad
|
||||||
|
Future<void> loadBannerAd();
|
||||||
|
|
||||||
|
/// Get banner widget to display
|
||||||
|
Widget getBannerWidget();
|
||||||
|
|
||||||
|
/// Dispose resources
|
||||||
|
void dispose();
|
||||||
|
|
||||||
|
/// Check if ads are loaded
|
||||||
|
bool get isLoaded;
|
||||||
|
}
|
||||||
73
lib/services/admob_service.dart
Normal file
73
lib/services/admob_service.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
import "package:google_mobile_ads/google_mobile_ads.dart";
|
||||||
|
import "ad_service.dart";
|
||||||
|
|
||||||
|
class AdMobService implements AdService {
|
||||||
|
BannerAd? _bannerAd;
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
// Ad unit IDs
|
||||||
|
static const String _androidBannerAdUnitId = "ca-app-pub-5177609929140951/8415596856";
|
||||||
|
static const String _iosBannerAdUnitId = "ca-app-pub-5177609929140951/7102515188";
|
||||||
|
|
||||||
|
String get _bannerAdUnitId {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
return _androidBannerAdUnitId;
|
||||||
|
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
return _iosBannerAdUnitId;
|
||||||
|
} else {
|
||||||
|
throw UnsupportedError("Platform not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {
|
||||||
|
await MobileAds.instance.initialize();
|
||||||
|
print("AdMob initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadBannerAd() async {
|
||||||
|
_bannerAd = BannerAd(
|
||||||
|
adUnitId: _bannerAdUnitId,
|
||||||
|
size: AdSize.banner,
|
||||||
|
request: const AdRequest(),
|
||||||
|
listener: BannerAdListener(
|
||||||
|
onAdLoaded: (ad) {
|
||||||
|
print("Banner ad loaded");
|
||||||
|
_isLoaded = true;
|
||||||
|
},
|
||||||
|
onAdFailedToLoad: (ad, error) {
|
||||||
|
print("Banner ad failed to load: $error");
|
||||||
|
ad.dispose();
|
||||||
|
_isLoaded = false;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _bannerAd!.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget getBannerWidget() {
|
||||||
|
if (_bannerAd == null || !_isLoaded) {
|
||||||
|
return SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: AdWidget(ad: _bannerAd!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bannerAd?.dispose();
|
||||||
|
_bannerAd = null;
|
||||||
|
_isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoaded => _isLoaded;
|
||||||
|
}
|
||||||
3
lib/services/adsense_service.dart
Normal file
3
lib/services/adsense_service.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Conditional export based on platform
|
||||||
|
export "adsense_service_stub.dart"
|
||||||
|
if (dart.library.js_interop) "adsense_service_web.dart";
|
||||||
32
lib/services/adsense_service_stub.dart
Normal file
32
lib/services/adsense_service_stub.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
import "ad_service.dart";
|
||||||
|
|
||||||
|
/// Stub implementation for non-web platforms
|
||||||
|
class AdSenseService implements AdService {
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {
|
||||||
|
// Not supported on non-web platforms
|
||||||
|
_isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadBannerAd() async {
|
||||||
|
// Not supported on non-web platforms
|
||||||
|
_isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget getBannerWidget() {
|
||||||
|
return SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoaded => _isLoaded;
|
||||||
|
}
|
||||||
80
lib/services/adsense_service_web.dart
Normal file
80
lib/services/adsense_service_web.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import "dart:ui_web" as ui_web;
|
||||||
|
import "dart:js" as js;
|
||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
import "package:universal_html/html.dart" as html;
|
||||||
|
import "ad_service.dart";
|
||||||
|
|
||||||
|
/// Web implementation of AdSense service
|
||||||
|
class AdSenseService implements AdService {
|
||||||
|
bool _isLoaded = false;
|
||||||
|
static const String _adSlotId = "XXXXXXXXXX"; // Replace with your ad slot ID
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {
|
||||||
|
// AdSense is loaded via script tag in index.html
|
||||||
|
print("AdSense initialized (web)");
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadBannerAd() async {
|
||||||
|
// AdSense ads are loaded automatically when widget renders
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget getBannerWidget() {
|
||||||
|
final viewType = "adsense-banner-${DateTime.now().millisecondsSinceEpoch}";
|
||||||
|
|
||||||
|
// Register the view factory
|
||||||
|
// ignore: undefined_prefixed_name
|
||||||
|
ui_web.platformViewRegistry.registerViewFactory(
|
||||||
|
viewType,
|
||||||
|
(int viewId) => _createAdElement(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 90,
|
||||||
|
child: HtmlElementView(viewType: viewType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.Element _createAdElement() {
|
||||||
|
// Create AdSense div
|
||||||
|
final adContainer = html.DivElement()
|
||||||
|
..style.width = "100%"
|
||||||
|
..style.height = "90px"
|
||||||
|
..style.textAlign = "center";
|
||||||
|
|
||||||
|
final adElement = html.Element.html("""
|
||||||
|
<ins class="adsbygoogle"
|
||||||
|
style="display:block"
|
||||||
|
data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
|
||||||
|
data-ad-slot="$_adSlotId"
|
||||||
|
data-ad-format="auto"
|
||||||
|
data-full-width-responsive="true"></ins>
|
||||||
|
""");
|
||||||
|
|
||||||
|
adContainer.append(adElement);
|
||||||
|
|
||||||
|
// Push ad using dart:js instead of eval
|
||||||
|
try {
|
||||||
|
final adsbygoogle = js.context["adsbygoogle"];
|
||||||
|
if (adsbygoogle != null) {
|
||||||
|
adsbygoogle.callMethod("push", [js.JsObject.jsify({})]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error pushing AdSense ad: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
return adContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoaded => _isLoaded;
|
||||||
|
}
|
||||||
@@ -111,7 +111,10 @@ class QuoteGeneratorApi {
|
|||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "QuoteGen-Flutter/1.0",
|
||||||
|
},
|
||||||
body: jsonEncode(requestBody),
|
body: jsonEncode(requestBody),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,7 +130,10 @@ class QuoteGeneratorApi {
|
|||||||
final queryString = request.toQueryString();
|
final queryString = request.toQueryString();
|
||||||
final url = Uri.parse("$_baseUrl/generate?$queryString");
|
final url = Uri.parse("$_baseUrl/generate?$queryString");
|
||||||
|
|
||||||
final response = await http.get(url);
|
final response = await http.get(
|
||||||
|
url,
|
||||||
|
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return response.bodyBytes;
|
return response.bodyBytes;
|
||||||
@@ -146,7 +152,10 @@ class QuoteGeneratorApi {
|
|||||||
static Future<bool> checkHealth() async {
|
static Future<bool> checkHealth() async {
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse("$_baseUrl/health");
|
final url = Uri.parse("$_baseUrl/health");
|
||||||
final response = await http.get(url);
|
final response = await http.get(
|
||||||
|
url,
|
||||||
|
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ class QuoteGeneratorApiV2 {
|
|||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "QuoteGen-Flutter/1.0",
|
||||||
|
},
|
||||||
body: jsonEncode(request.toJson()),
|
body: jsonEncode(request.toJson()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -178,7 +181,7 @@ class QuoteGeneratorApiV2 {
|
|||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
return QuoteSession.fromJson(data);
|
return QuoteSession.fromJson(data);
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Failed to create sesion: ${response.statusCode}");
|
throw Exception("Failed to create session: ${response.statusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +190,10 @@ class QuoteGeneratorApiV2 {
|
|||||||
static Future<QuoteSession> getSession(String sessionId) async {
|
static Future<QuoteSession> getSession(String sessionId) async {
|
||||||
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId");
|
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId");
|
||||||
|
|
||||||
final response = await http.get(url);
|
final response = await http.get(
|
||||||
|
url,
|
||||||
|
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
@@ -206,7 +212,10 @@ class QuoteGeneratorApiV2 {
|
|||||||
|
|
||||||
final response = await http.patch(
|
final response = await http.patch(
|
||||||
url,
|
url,
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "QuoteGen-Flutter/1.0",
|
||||||
|
},
|
||||||
body: jsonEncode(updates.toJson()),
|
body: jsonEncode(updates.toJson()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,7 +223,7 @@ class QuoteGeneratorApiV2 {
|
|||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
return QuoteSession.fromJson(data);
|
return QuoteSession.fromJson(data);
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Failed to updte session: ${response.statusCode}");
|
throw Exception("Failed to update session: ${response.statusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +231,15 @@ class QuoteGeneratorApiV2 {
|
|||||||
static Future<Uint8List> generateImage(String sessionId) async {
|
static Future<Uint8List> generateImage(String sessionId) async {
|
||||||
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image");
|
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId/image");
|
||||||
|
|
||||||
final response = await http.get(url);
|
final response = await http.get(
|
||||||
|
url,
|
||||||
|
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return response.bodyBytes;
|
return response.bodyBytes;
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Failed to genrate image: ${response.statusCode}");
|
throw Exception("Failed to generate image: ${response.statusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,15 +248,18 @@ class QuoteGeneratorApiV2 {
|
|||||||
static Future<void> deleteSession(String sessionId) async {
|
static Future<void> deleteSession(String sessionId) async {
|
||||||
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId");
|
final url = Uri.parse("$_baseUrl/v2/quote/$sessionId");
|
||||||
|
|
||||||
final response = await http.delete(url);
|
final response = await http.delete(
|
||||||
|
url,
|
||||||
|
headers: {"User-Agent": "QuoteGen-Flutter/1.0"},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode != 204) {
|
if (response.statusCode != 204) {
|
||||||
throw Exception("Failed to delte session: ${response.statusCode}");
|
throw Exception("Failed to delete session: ${response.statusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// helper to get curren timestamp in seconds
|
// helper to get current timestamp in seconds
|
||||||
static int getCurrentTimestamp() {
|
static int getCurrentTimestamp() {
|
||||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
75
lib/widgets/ad_banner.dart
Normal file
75
lib/widgets/ad_banner.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import "package:flutter/foundation.dart" show kIsWeb;
|
||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||||
|
import "../services/ad_service.dart";
|
||||||
|
import "../services/admob_service.dart";
|
||||||
|
import "../services/adsense_service.dart";
|
||||||
|
|
||||||
|
class AdBanner extends StatefulWidget {
|
||||||
|
const AdBanner({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdBanner> createState() => _AdBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdBannerState extends State<AdBanner> {
|
||||||
|
late AdService _adService;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAds();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeAds() async {
|
||||||
|
// Platform detection
|
||||||
|
if (kIsWeb) {
|
||||||
|
_adService = AdSenseService();
|
||||||
|
} else {
|
||||||
|
_adService = AdMobService();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _adService.initialize();
|
||||||
|
await _adService.loadBannerAd();
|
||||||
|
} catch (e) {
|
||||||
|
print("Error initializng ads: $e");
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_adService.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_adService.isLoaded) {
|
||||||
|
// Ad failed to load, show nothing
|
||||||
|
return SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _adService.getBannerWidget();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import file_picker
|
|||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
30
nginx.conf
Normal file
30
nginx.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main Flutter app
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
80
pubspec.lock
80
pubspec.lock
@@ -41,6 +41,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -208,6 +224,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.0.1"
|
version: "17.0.1"
|
||||||
|
google_mobile_ads:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_mobile_ads
|
||||||
|
sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.3.1"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -589,6 +621,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_html:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: universal_html
|
||||||
|
sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
universal_io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_io
|
||||||
|
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -653,6 +701,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webview_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter
|
||||||
|
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.13.0"
|
||||||
|
webview_flutter_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_android
|
||||||
|
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.10.11"
|
||||||
|
webview_flutter_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_platform_interface
|
||||||
|
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.14.0"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webview_flutter_wkwebview
|
||||||
|
sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.23.5"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
|
|
||||||
<title>quotegen_client</title>
|
<title>quotegen_client</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
|
||||||
|
<!-- Google AdSense -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user