You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
9.6 KiB
Dart
207 lines
9.6 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:app_links/app_links.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:mc_common_app/config/routes.dart';
|
|
import 'package:mc_common_app/generated/locale_keys.g.dart';
|
|
import 'package:mc_common_app/models/advertisment_models/ad_details_model.dart';
|
|
import 'package:mc_common_app/utils/utils.dart';
|
|
import 'package:mc_common_app/view_models/ad_view_model.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
/// Handles opening the ad detail screen directly when the app is launched or
|
|
/// resumed via a shared ad link, for Android App Links / iOS Universal Links
|
|
/// (https://ms.hmg.com/ad/1541) AND the custom-scheme fallback used by that
|
|
/// same web page when App Links verification isn't active
|
|
/// (mowatercustomer://ad/1541, mowaterprovider://ad/1541).
|
|
class AdDeepLinkService {
|
|
static final AppLinks _appLinks = AppLinks();
|
|
static StreamSubscription<Uri>? _subscription;
|
|
static Uri? _lastHandledUri;
|
|
|
|
// Route name given to the inert, self-removing placeholder pushed from main.dart's
|
|
// onUnknownRoute for Android's native deep-link route forwarding (see that file for
|
|
// why it exists). It's pushed and removed again within a frame, so the route-settle
|
|
// polling below must ignore it rather than treat its brief presence/absence as a real
|
|
// navigation change -- otherwise the route name never reads as "stable" and _openAd
|
|
// gives up without ever opening the ad.
|
|
static const String ignoredPlaceholderRouteName = '__ad_deep_link_ignore__';
|
|
|
|
static String? _currentTopRouteName;
|
|
|
|
/// Register via MaterialApp(navigatorObservers: [AdDeepLinkService.routeObserver]).
|
|
/// This is the only reliable way to know the current top route name from here --
|
|
/// confirmed on a real device: ModalRoute.of(navigatorKey.currentContext) does NOT
|
|
/// work for this. navigatorKey.currentContext is the Navigator widget's OWN element,
|
|
/// which sits ABOVE all its routes in the tree, so it has no ModalRoute ancestor and
|
|
/// that call returns null essentially always. That silently made every route-settle
|
|
/// poll below read null forever, so _openAd always timed out and gave up without ever
|
|
/// opening the ad, regardless of login state or what screen was actually showing.
|
|
static final NavigatorObserver routeObserver = _RouteNameTracker();
|
|
|
|
/// Call once, after the app's first frame (so [navigatorKey] is attached to a Navigator).
|
|
static Future<void> init() async {
|
|
debugPrint("AdDeepLinkService: init() called");
|
|
try {
|
|
final Uri? initialUri = await _appLinks.getInitialLink();
|
|
debugPrint("AdDeepLinkService: getInitialLink() -> $initialUri");
|
|
if (initialUri != null) {
|
|
_handleUri(initialUri);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("AdDeepLinkService: failed to read initial link - $e");
|
|
}
|
|
|
|
_subscription?.cancel();
|
|
_subscription = _appLinks.uriLinkStream.listen(
|
|
(uri) {
|
|
debugPrint("AdDeepLinkService: uriLinkStream emitted $uri");
|
|
_handleUri(uri);
|
|
},
|
|
onError: (Object e) => debugPrint("AdDeepLinkService: stream error - $e"),
|
|
);
|
|
debugPrint("AdDeepLinkService: uriLinkStream subscription active");
|
|
}
|
|
|
|
static void _handleUri(Uri uri) {
|
|
debugPrint("AdDeepLinkService: received $uri");
|
|
|
|
// https://ms.hmg.com/ad/1541 -> host="ms.hmg.com", pathSegments=["ad", "1541"]
|
|
// mowatercustomer://ad/1541 -> host="ad" (Dart treats it as the authority), pathSegments=["1541"]
|
|
int? adId;
|
|
if (uri.scheme == 'https') {
|
|
final segments = uri.pathSegments;
|
|
if (segments.length >= 2 && segments[0] == 'ad') {
|
|
adId = int.tryParse(segments[1]);
|
|
}
|
|
} else if (uri.host == 'ad' && uri.pathSegments.isNotEmpty) {
|
|
adId = int.tryParse(uri.pathSegments.first);
|
|
}
|
|
|
|
if (adId == null) return;
|
|
|
|
// app_links can emit the same initial uri twice (once via getInitialLink, once as
|
|
// the stream's first event) -- skip the duplicate rather than run _openAd twice.
|
|
if (uri == _lastHandledUri) {
|
|
debugPrint("AdDeepLinkService: ignoring duplicate $uri");
|
|
return;
|
|
}
|
|
_lastHandledUri = uri;
|
|
_openAd(adId);
|
|
}
|
|
|
|
// Deliberately no loading dialog anywhere in here: on a cold start (app was killed),
|
|
// this races the app's own bootstrap navigation -- confirmed on a real device, the
|
|
// bootstrap chain is SplashPage's hardcoded 5s timer -> pushReplacementNamed to
|
|
// registerSelection -> (if a saved login exists) a silent auto-login with its own
|
|
// showLoading/hideLoading dialog -> pushReplacementNamed again towards the dashboard.
|
|
// Every one of those calls uses a *captured* BuildContext and mutates the same
|
|
// Navigator via Navigator.of(context), regardless of what else has since been pushed
|
|
// on top. If we push the ad route while any of that is still in flight, our route gets
|
|
// clobbered by the next replacement -- in debug this throws "_elements.contains
|
|
// (element)"; in release (asserts stripped) it silently wedges the Navigator and the
|
|
// app hangs on a black screen. So: wait for the current route name to stay the same
|
|
// across two consecutive polls (i.e. bootstrap has actually settled) before touching
|
|
// the Navigator or fetching anything.
|
|
static Future<void> _openAd(int adId) async {
|
|
debugPrint("AdDeepLinkService: _openAd($adId) called");
|
|
if (!await _waitUntilRouteSettles()) {
|
|
debugPrint("AdDeepLinkService: app never settled past bootstrap, aborting");
|
|
return;
|
|
}
|
|
debugPrint("AdDeepLinkService: route settled at '$_currentTopRouteName'");
|
|
|
|
// Checking for a saved token here is NOT a reliable "is the user ready" signal --
|
|
// confirmed on a real device: a stale token from before the app was killed survives
|
|
// on disk through the whole re-login flow (splash -> registerSelection -> silent
|
|
// relogin -> loginMethodSelection OTP screen), so it reads as "already logged in"
|
|
// while still sitting on the OTP screen, fetches too early with no real session
|
|
// ready yet, and silently fails. The dashboard route is the one signal that's only
|
|
// true once a real session is actually in place, however the user got there.
|
|
if (_currentTopRouteName != AppRoutes.dashboard) {
|
|
debugPrint("AdDeepLinkService: not on dashboard yet ('$_currentTopRouteName'), waiting for login to finish");
|
|
if (!await _waitForRoute(AppRoutes.dashboard)) {
|
|
debugPrint("AdDeepLinkService: gave up waiting for dashboard, aborting");
|
|
return;
|
|
}
|
|
debugPrint("AdDeepLinkService: reached dashboard");
|
|
} else {
|
|
debugPrint("AdDeepLinkService: already on dashboard, proceeding directly");
|
|
}
|
|
|
|
debugPrint("AdDeepLinkService: fetching ad $adId");
|
|
AdDetailsModel? adDetails;
|
|
try {
|
|
adDetails = await navigatorKey.currentContext?.read<AdVM>().getAdDetailsById(adId);
|
|
debugPrint("AdDeepLinkService: fetch result adDetails=${adDetails != null ? 'FOUND id=${adDetails.id}' : 'NULL'}");
|
|
} catch (e) {
|
|
debugPrint("AdDeepLinkService: failed to fetch ad $adId - $e");
|
|
}
|
|
|
|
if (adDetails == null) {
|
|
Utils.showToast(LocaleKeys.somethingWrong.tr());
|
|
return;
|
|
}
|
|
|
|
// Navigate via the NavigatorState directly (not a captured BuildContext) so this
|
|
// still works even if the route/context active when we started has since changed.
|
|
debugPrint("AdDeepLinkService: pushing ${AppRoutes.adsDetailView}");
|
|
navigatorKey.currentState?.pushNamed(AppRoutes.adsDetailView, arguments: adDetails);
|
|
}
|
|
|
|
static String? _currentRouteNameIgnoringPlaceholder() {
|
|
final name = _currentTopRouteName;
|
|
// The placeholder pops itself a frame after being pushed -- if this poll happens to
|
|
// land exactly then, pretend nothing was read rather than counting it as a route.
|
|
return name == ignoredPlaceholderRouteName ? null : name;
|
|
}
|
|
|
|
static Future<bool> _waitUntilRouteSettles({int maxAttempts = 60, Duration retryDelay = const Duration(milliseconds: 300)}) async {
|
|
String? lastRouteName;
|
|
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
final currentRouteName = _currentRouteNameIgnoringPlaceholder();
|
|
|
|
if (currentRouteName != null && currentRouteName != AppRoutes.splash && currentRouteName == lastRouteName) {
|
|
return true;
|
|
}
|
|
if (currentRouteName != null) lastRouteName = currentRouteName;
|
|
if (attempt < maxAttempts) await Future.delayed(retryDelay);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Used both for the short "did bootstrap settle" check and for waiting on the
|
|
// dashboard specifically, which can be user-paced (entering an OTP, picking a login
|
|
// method) -- default timeout is generous (5 minutes) to cover that case.
|
|
static Future<bool> _waitForRoute(String targetRoute, {int maxAttempts = 600, Duration retryDelay = const Duration(milliseconds: 500)}) async {
|
|
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
if (_currentRouteNameIgnoringPlaceholder() == targetRoute) return true;
|
|
if (attempt < maxAttempts) await Future.delayed(retryDelay);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class _RouteNameTracker extends NavigatorObserver {
|
|
@override
|
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
AdDeepLinkService._currentTopRouteName = route.settings.name;
|
|
}
|
|
|
|
@override
|
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name;
|
|
}
|
|
|
|
@override
|
|
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name;
|
|
}
|
|
|
|
@override
|
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
|
AdDeepLinkService._currentTopRouteName = newRoute?.settings.name;
|
|
}
|
|
}
|