Skip to content

Commit 187857d

Browse files
Merge branch 'dev' into release
# Conflicts: # pubspec.yaml
2 parents 6bc9f08 + 9f6f3fa commit 187857d

10 files changed

Lines changed: 491 additions & 275 deletions

File tree

lib/app/ad_director_provider.dart

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
2+
import 'dart:io';
3+
import 'package:defyx_vpn/app/advertise_director.dart';
4+
import 'package:defyx_vpn/modules/main/presentation/widgets/ads/ads_state.dart';
5+
import 'package:defyx_vpn/modules/main/presentation/widgets/ads/strategy/ad_loading_strategy.dart';
6+
import 'package:defyx_vpn/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart';
7+
import 'package:defyx_vpn/modules/main/presentation/widgets/ads/strategy/internal_ad_strategy.dart';
8+
import 'package:defyx_vpn/shared/providers/connection_state_provider.dart' as conn;
9+
import 'package:flutter/material.dart';
10+
import 'package:flutter_riverpod/flutter_riverpod.dart';
11+
12+
/// Ad Environment Configuration
13+
///
14+
/// Cached environment details to avoid repeated timezone/platform detection.
15+
/// This is computed once at app startup and used throughout the app lifecycle.
16+
class AdEnvironment {
17+
final bool isIranian;
18+
final bool isMobilePlatform;
19+
final bool shouldInitializeAdMob;
20+
21+
const AdEnvironment({
22+
required this.isIranian,
23+
required this.isMobilePlatform,
24+
required this.shouldInitializeAdMob,
25+
});
26+
27+
@override
28+
String toString() => 'AdEnvironment(isIranian: $isIranian, isMobile: $isMobilePlatform, initAdMob: $shouldInitializeAdMob)';
29+
}
30+
31+
/// Provider for ad environment configuration
32+
///
33+
/// This is a FutureProvider because Iran detection is async (requires timezone check).
34+
/// It's computed once and cached for the app lifetime.
35+
///
36+
/// Business rules:
37+
/// - Iranian users: Don't initialize AdMob, only use internal ads
38+
/// - Desktop platforms: Don't initialize AdMob, only use internal ads
39+
/// - Mobile non-Iranian: Initialize AdMob, use both strategies
40+
final adEnvironmentProvider = FutureProvider<AdEnvironment>((ref) async {
41+
debugPrint('🌍 Computing ad environment...');
42+
43+
final isIranian = await AdvertiseDirector.isIranianUser();
44+
final isMobile = Platform.isAndroid || Platform.isIOS;
45+
final shouldInitAdMob = isMobile && !isIranian;
46+
47+
final environment = AdEnvironment(
48+
isIranian: isIranian,
49+
isMobilePlatform: isMobile,
50+
shouldInitializeAdMob: shouldInitAdMob,
51+
);
52+
53+
debugPrint('🌍 Ad environment: $environment');
54+
return environment;
55+
});
56+
57+
/// Ad Strategy Manager - Handles strategy selection based on business rules
58+
///
59+
/// Responsibilities:
60+
/// - Owns strategy instances (InternalAdStrategy and GoogleAdStrategy)
61+
/// - Decides which strategy is active based on connection state and user type
62+
/// - Orchestrates transitions between strategies
63+
/// - Enforces business rules (Iranian users, desktop users, etc.)
64+
/// - Manages connection state listener lifecycle
65+
class AdStrategyManager {
66+
final Ref _ref;
67+
final AdEnvironment _environment;
68+
final InternalAdStrategy _internalStrategy;
69+
final GoogleAdStrategy? _googleStrategy; // null for Iranian/desktop users
70+
final bool _hasGoogleStrategy;
71+
72+
ProviderSubscription<conn.ConnectionState>? _connectionSubscription;
73+
74+
/// Private constructor - use factory method
75+
AdStrategyManager._({
76+
required Ref ref,
77+
required AdEnvironment environment,
78+
required InternalAdStrategy internalStrategy,
79+
required GoogleAdStrategy? googleStrategy,
80+
}) : _ref = ref,
81+
_environment = environment,
82+
_internalStrategy = internalStrategy,
83+
_googleStrategy = googleStrategy,
84+
_hasGoogleStrategy = googleStrategy != null;
85+
86+
/// Factory constructor - creates strategies and sets up listener
87+
factory AdStrategyManager.create({
88+
required Ref ref,
89+
required AdEnvironment environment,
90+
Color backgroundColor = const Color(0xFF19312F),
91+
double cornerRadius = 10.0,
92+
}) {
93+
debugPrint('🏭 AdStrategyManager.create() - Environment: $environment');
94+
95+
// Create InternalAdStrategy (always needed)
96+
final internalStrategy = InternalAdStrategy(
97+
backgroundColor: backgroundColor,
98+
cornerRadius: cornerRadius,
99+
);
100+
101+
// Create GoogleAdStrategy only for mobile non-Iranian users
102+
final googleStrategy = environment.shouldInitializeAdMob
103+
? GoogleAdStrategy(
104+
backgroundColor: backgroundColor,
105+
cornerRadius: cornerRadius,
106+
)
107+
: null;
108+
109+
final manager = AdStrategyManager._(
110+
ref: ref,
111+
environment: environment,
112+
internalStrategy: internalStrategy,
113+
googleStrategy: googleStrategy,
114+
);
115+
116+
// Initialize connection listener
117+
manager._initializeConnectionListener();
118+
119+
return manager;
120+
}
121+
122+
/// Initialize connection state listener
123+
void _initializeConnectionListener() {
124+
debugPrint('🔔 AdStrategyManager - Registering connection listener');
125+
debugPrint(' Environment: ${_environment}');
126+
127+
// Listen to connection changes and delegate to strategies
128+
_connectionSubscription = _ref.listen(
129+
conn.connectionStateProvider,
130+
(previous, next) {
131+
debugPrint('🔔 Connection listener FIRED: ${previous?.status.name ?? "null"} → ${next.status.name}');
132+
133+
final prevStatus = previous?.status ?? conn.ConnectionStatus.disconnected;
134+
final currentStatus = next.status;
135+
136+
_handleConnectionStateChanged(
137+
previous: prevStatus,
138+
current: currentStatus,
139+
);
140+
},
141+
);
142+
143+
// Trigger initial state
144+
final currentState = _ref.read(conn.connectionStateProvider).status;
145+
debugPrint('🔄 Triggering initial connection state: ${currentState.name}');
146+
_handleConnectionStateChanged(
147+
previous: conn.ConnectionStatus.disconnected,
148+
current: currentState,
149+
);
150+
}
151+
152+
/// Get active strategy based on connection state
153+
/// Returns null if no ad should be shown
154+
AdLoadingStrategy? getActiveStrategy(conn.ConnectionStatus connectionState) {
155+
switch (connectionState) {
156+
case conn.ConnectionStatus.connected:
157+
// Connected state: Internal ads for all users
158+
return _internalStrategy;
159+
160+
case conn.ConnectionStatus.disconnected:
161+
// Disconnected state: AdMob for non-Iranian users, nothing for Iranian users
162+
return _googleStrategy; // null for Iranian users = no ad
163+
164+
default:
165+
// Intermediate states (connecting, loading, etc.): no ad
166+
return null;
167+
}
168+
}
169+
170+
/// Handle connection state changes - orchestrates strategy transitions
171+
/// This is called internally by the connection listener
172+
void _handleConnectionStateChanged({
173+
required conn.ConnectionStatus previous,
174+
required conn.ConnectionStatus current,
175+
}) {
176+
debugPrint('🎯 AdStrategyManager - Connection: ${previous.name} → ${current.name}');
177+
178+
// When connecting: notify internal strategy
179+
if (current == conn.ConnectionStatus.connected && previous != conn.ConnectionStatus.connected) {
180+
debugPrint(' → Activating InternalAdStrategy');
181+
_internalStrategy.onConnectionStateChanged(
182+
ref: _ref,
183+
previous: previous,
184+
current: current,
185+
hasInitialized: true,
186+
onRefreshNeeded: () => _internalStrategy.loadAd(ref: _ref),
187+
);
188+
}
189+
190+
// When leaving connected state: Clean up internal ads immediately
191+
// This handles: connected → disconnecting, connected → disconnected
192+
else if (previous == conn.ConnectionStatus.connected && current != conn.ConnectionStatus.connected) {
193+
debugPrint(' → Leaving connected state, deactivating InternalAdStrategy');
194+
_internalStrategy.onConnectionStateChanged(
195+
ref: _ref,
196+
previous: previous,
197+
current: current,
198+
hasInitialized: true,
199+
onRefreshNeeded: () => _internalStrategy.loadAd(ref: _ref),
200+
);
201+
}
202+
203+
// When reaching disconnected state: Load AdMob ad (if available)
204+
// This handles: disconnecting → disconnected, connected → disconnected, anything → disconnected
205+
if (current == conn.ConnectionStatus.disconnected && previous != conn.ConnectionStatus.disconnected) {
206+
if (_hasGoogleStrategy) {
207+
debugPrint(' → Reached disconnected state, activating GoogleAdStrategy');
208+
_googleStrategy!.onConnectionStateChanged(
209+
ref: _ref,
210+
previous: previous,
211+
current: current,
212+
hasInitialized: true,
213+
onRefreshNeeded: () => _googleStrategy.loadAd(ref: _ref),
214+
);
215+
} else {
216+
debugPrint(' → No GoogleAdStrategy available (Iranian/Desktop user)');
217+
}
218+
}
219+
}
220+
221+
/// Initialize both strategies
222+
Future<void> initialize() async {
223+
debugPrint('🚀 AdStrategyManager - Initializing strategies');
224+
await _internalStrategy.initialize(_ref);
225+
if (_hasGoogleStrategy) {
226+
await _googleStrategy!.initialize(_ref);
227+
}
228+
debugPrint('✅ AdStrategyManager - Initialization complete');
229+
}
230+
231+
/// Dispose strategies and subscriptions
232+
void dispose() {
233+
debugPrint('🧹 AdStrategyManager - Disposing');
234+
_connectionSubscription?.close();
235+
_internalStrategy.dispose();
236+
_googleStrategy?.dispose();
237+
debugPrint('✅ AdStrategyManager - Disposed');
238+
}
239+
240+
InternalAdStrategy get internalStrategy => _internalStrategy;
241+
GoogleAdStrategy? get googleStrategy => _googleStrategy;
242+
bool get hasGoogleStrategy => _hasGoogleStrategy;
243+
}
244+
245+
/// Provider for AdStrategyManager
246+
///
247+
/// Creates and manages the ad strategy manager instance.
248+
/// The manager is created lazily when first accessed and disposed automatically.
249+
///
250+
/// Dependencies:
251+
/// - adEnvironmentProvider: To determine which strategies to create
252+
///
253+
/// Lifecycle:
254+
/// - autoDispose: Cleans up when no longer watched
255+
/// - ref.onDispose: Ensures manager.dispose() is called
256+
final adStrategyManagerProvider = Provider.autoDispose<AdStrategyManager?>((ref) {
257+
// Wait for environment to be ready
258+
final environmentAsync = ref.watch(adEnvironmentProvider);
259+
260+
return environmentAsync.when(
261+
data: (environment) {
262+
debugPrint('📦 Creating AdStrategyManager from provider');
263+
264+
// Create manager with environment
265+
final manager = AdStrategyManager.create(
266+
ref: ref,
267+
environment: environment,
268+
);
269+
270+
// Initialize strategies synchronously
271+
// (actual ad loading happens on connection changes, which is async)
272+
manager.initialize();
273+
274+
// Register disposal
275+
ref.onDispose(() {
276+
debugPrint('📦 AdStrategyManager provider disposing');
277+
manager.dispose();
278+
});
279+
280+
return manager;
281+
},
282+
loading: () {
283+
debugPrint('⏳ Waiting for ad environment...');
284+
return null;
285+
},
286+
error: (error, stack) {
287+
debugPrint('❌ Error loading ad environment: $error');
288+
return null;
289+
},
290+
);
291+
});
292+
293+
/// Provider for active ad visibility
294+
///
295+
/// Returns true if there's an active ad strategy that should be displayed.
296+
/// This is the single source of truth for ad container visibility.
297+
///
298+
/// Checks not just if a strategy exists, but if it has actually loaded an ad.
299+
/// This prevents showing empty ad containers:
300+
/// - Before first VPN connection (hasCompletedFirstConnection = false)
301+
/// - When GoogleAdStrategy hasn't loaded yet (nativeAdIsLoaded = false)
302+
/// - When InternalAdStrategy has no ad data (customImageUrl = null/empty)
303+
final hasActiveAdProvider = Provider.autoDispose<bool>((ref) {
304+
final manager = ref.watch(adStrategyManagerProvider);
305+
final connectionState = ref.watch(conn.connectionStateProvider);
306+
final adsState = ref.watch(adsProvider);
307+
308+
if (manager == null) {
309+
debugPrint('🎯 hasActiveAdProvider: false (manager not ready)');
310+
return false;
311+
}
312+
313+
final activeStrategy = manager.getActiveStrategy(connectionState.status);
314+
315+
if (activeStrategy == null) {
316+
debugPrint('🎯 hasActiveAdProvider: false (no active strategy)');
317+
return false;
318+
}
319+
320+
// Check if the strategy has actually loaded an ad
321+
bool hasLoadedAd = false;
322+
323+
if (activeStrategy is GoogleAdStrategy) {
324+
// GoogleAdStrategy: Must have both ad loaded AND user completed first connection
325+
hasLoadedAd = adsState.nativeAdIsLoaded &&
326+
adsState.hasCompletedFirstConnection;
327+
debugPrint('🎯 hasActiveAdProvider: GoogleAdStrategy - nativeAdIsLoaded=${adsState.nativeAdIsLoaded}, hasCompletedFirstConnection=${adsState.hasCompletedFirstConnection}, result=$hasLoadedAd');
328+
} else if (activeStrategy is InternalAdStrategy) {
329+
// InternalAdStrategy: Must have custom ad image URL
330+
hasLoadedAd = adsState.customImageUrl != null &&
331+
adsState.customImageUrl!.isNotEmpty;
332+
debugPrint('🎯 hasActiveAdProvider: InternalAdStrategy - hasCustomImage=${adsState.customImageUrl != null && adsState.customImageUrl!.isNotEmpty}, result=$hasLoadedAd');
333+
}
334+
335+
debugPrint('🎯 hasActiveAdProvider: $hasLoadedAd (connection: ${connectionState.status.name}, strategy: ${activeStrategy.strategyName})');
336+
337+
return hasLoadedAd;
338+
});

lib/app/advertise_director.dart

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,38 @@ class AdvertiseDirector {
1111

1212
AdvertiseDirector(this.ref);
1313

14-
static Future<bool> shouldUseInternalAds(WidgetRef ref) async {
14+
/// Check if user is from Iran based on device timezone
15+
/// Iranian users should not see AdMob ads (only internal ads)
16+
static Future<bool> isIranianUser() async {
17+
try {
18+
final String currentTimeZone = await FlutterTimezone.getLocalTimezone();
19+
// Asia/Tehran is the timezone for Iran
20+
final isIran = currentTimeZone == 'Asia/Tehran';
21+
if (isIran) {
22+
debugPrint('🇮🇷 Iranian user detected (timezone: $currentTimeZone) - AdMob disabled');
23+
}
24+
return isIran;
25+
} catch (e) {
26+
debugPrint('⚠️ Error detecting timezone: $e');
27+
return false;
28+
}
29+
}
30+
31+
static Future<bool> shouldUseInternalAds(Ref ref) async {
1532
// STRATEGY SELECTION (for backward compatibility with desktop):
1633
// - Desktop (Windows/macOS/Linux) → InternalAdStrategy only (no AdMob support)
34+
// - Iranian users → InternalAdStrategy only (AdMob disabled for Iran)
1735
// - Mobile (Android/iOS) → DUAL strategy approach:
1836
// * GoogleAdStrategy handles AdMob ads (disconnected state ONLY)
1937
// * InternalAdStrategy handles internal ads (connected state ONLY)
2038
// * AdsWidget coordinates between the two strategies
2139

40+
// Check for Iranian users first (AdMob disabled for Iran)
41+
if (await isIranianUser()) {
42+
debugPrint('📍 Ad Manager - Iranian user detected, using InternalAdStrategy only (AdMob disabled)');
43+
return true;
44+
}
45+
2246
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
2347
debugPrint('📍 Ad Manager - Desktop platform detected, using InternalAdStrategy only');
2448
return true;
@@ -42,17 +66,17 @@ class AdvertiseDirector {
4266
return false; // Mobile uses dual strategy (both GoogleAdStrategy + InternalAdStrategy)
4367
}
4468

45-
static Future<String> getCustomAdBanner(WidgetRef ref) async {
69+
static Future<String> getCustomAdBanner(Ref ref) async {
4670
final adData = await getRandomCustomAd(ref);
4771
return adData['imageUrl'] ?? '';
4872
}
4973

50-
static Future<String> getCustomAdClickUrl(WidgetRef ref) async {
74+
static Future<String> getCustomAdClickUrl(Ref ref) async {
5175
final adData = await getRandomCustomAd(ref);
5276
return adData['clickUrl'] ?? '';
5377
}
5478

55-
static Future<Map<String, String>> getRandomCustomAd(WidgetRef ref) async {
79+
static Future<Map<String, String>> getRandomCustomAd(Ref ref) async {
5680
final String currentTimeZone = await FlutterTimezone.getLocalTimezone();
5781
debugPrint('📍 Ad Manager - Getting ad for timezone: $currentTimeZone');
5882

0 commit comments

Comments
 (0)