Forked from clement-earnipay/flutter_bloc_tab_navigation_service.dart
Created
August 15, 2025 14:46
-
-
Save clembabs/c1feecff7f4a5dc09d26b75a9b3d081e to your computer and use it in GitHub Desktop.
Flutter Onesignal deeplinking service
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class DeeplinkService { | |
| static final DeeplinkService _instance = DeeplinkService._internal(); | |
| factory DeeplinkService() => _instance; | |
| DeeplinkService._internal(); | |
| final AppLinks _appLinks = AppLinks(); | |
| StreamSubscription? _subscription; | |
| bool _isInitialized = false; | |
| static _QueuedLink? _queuedDeeplink; // only one at a time | |
| String? _lastHandledDeeplink; | |
| DateTime? _lastHandledTime; | |
| void initialize() { | |
| if (_isInitialized) return; | |
| _listenForAppLinks(); | |
| _initializeOneSignalListeners(); | |
| _isInitialized = true; | |
| } | |
| /// ----------------------------- | |
| /// App Links (URL scheme / universal links) | |
| /// ----------------------------- | |
| void _listenForAppLinks() { | |
| _handleInitialLink(); // first launch | |
| _subscription = _appLinks.uriLinkStream.listen( | |
| (uri) => _handleWebDeeplink(uri.toString()), | |
| onError: (err) => debugPrint('Deeplink error: $err'), | |
| ); | |
| } | |
| Future<void> _handleInitialLink() async { | |
| try { | |
| final uri = await _appLinks.getInitialAppLink(); | |
| if (uri != null) _handleWebDeeplink(uri.toString()); | |
| } catch (e) { | |
| // debugPrint('Error getting initial link: $e'); | |
| } | |
| } | |
| /// ----------------------------- | |
| /// Push Notifications | |
| /// ----------------------------- | |
| void _initializeOneSignalListeners() { | |
| OneSignal.Notifications.addClickListener(_handleNotificationClicked); | |
| } | |
| void _handleNotificationClicked(OSNotificationClickEvent event) { | |
| // debugPrint( | |
| // 'Notification clicked: ${event.notification.jsonRepresentation()}', | |
| // ); | |
| _processNotificationDeeplink(event.notification); | |
| } | |
| void _processNotificationDeeplink(OSNotification notification) { | |
| try { | |
| final deeplink = | |
| notification.additionalData?['deeplink'] as String? ?? | |
| notification.launchUrl; | |
| if (deeplink != null) { | |
| _handleOneSignalDeeplink(deeplink); | |
| } else { | |
| // debugPrint('No deeplink found in notification'); | |
| } | |
| } catch (e) { | |
| // debugPrint('Error processing notification deeplink: $e'); | |
| } | |
| } | |
| /// ----------------------------- | |
| /// Main Deep Link Handler | |
| /// ----------------------------- | |
| void _handleDeeplink(String link, {required String source}) { | |
| if (_lastHandledDeeplink == link && | |
| _lastHandledTime != null && | |
| DateTime.now().difference(_lastHandledTime!) < | |
| const Duration(seconds: 2)) { | |
| // debugPrint('Duplicate deeplink ignored: $link from $source'); | |
| return; | |
| } | |
| _lastHandledDeeplink = link; | |
| _lastHandledTime = DateTime.now(); | |
| // debugPrint('[$source] Received deeplink: $link'); | |
| try { | |
| final uri = Uri.parse(link); | |
| final path = uri.path.isNotEmpty ? uri.path : "/${uri.host}"; | |
| final params = uri.queryParameters; | |
| if (source == 'onesignal' && uri.scheme == 'cheda') { | |
| _navigateByPath(path, params); | |
| } else if (source == 'applinks' && uri.scheme.startsWith('https')) { | |
| _navigateByPath(path, params); | |
| } else { | |
| debugPrint('Source mismatch or unsupported link: $link'); | |
| } | |
| } catch (e) { | |
| // debugPrint('Error parsing deeplink: $e'); | |
| } | |
| } | |
| void _handleOneSignalDeeplink(String link) { | |
| _handleDeeplink(link, source: 'onesignal'); | |
| } | |
| void _handleWebDeeplink(String link) { | |
| _handleDeeplink(link, source: 'applinks'); | |
| } | |
| /// Single navigation decision table | |
| void _navigateByPath(String path, Map<String, String>? params) { | |
| final context = Routes.navKey.currentContext; | |
| if (context == null) return; | |
| switch (path) { | |
| case '/verify-identity': | |
| _checkAuthAndNavigate(context, Routes.verifyIdentity, params); | |
| break; | |
| case '/home': | |
| _checkAuthAndNavigate(context, Routes.home, params); | |
| break; | |
| case '/bank-accounts': | |
| _checkAuthAndNavigate(context, Routes.bankAccounts, params); | |
| break; | |
| case '/profile': | |
| _checkAuthAndNavigate(context, Routes.profile, params); | |
| break; | |
| case '/assets': | |
| _checkAuthAndNavigate(context, Routes.home, params, tabIndex: 1); | |
| break; | |
| case '/history': | |
| _checkAuthAndNavigate(context, Routes.home, params, tabIndex: 2); | |
| break; | |
| default: | |
| debugPrint('Unknown deeplink path: $path'); | |
| } | |
| } | |
| /// ----------------------------- | |
| /// Authentication + Queuing | |
| /// ----------------------------- | |
| void _checkAuthAndNavigate( | |
| BuildContext context, | |
| String route, | |
| Map<String, String>? params, { | |
| int? tabIndex, | |
| }) { | |
| try { | |
| final authBloc = context.read<AuthenticationBloc>(); | |
| if (authBloc.isSignedIn) { | |
| _performNavigation(context, route, params, tabIndex: tabIndex); | |
| } else { | |
| _queueDeeplink(route, params, tabIndex: tabIndex); | |
| Navigator.of( | |
| context, | |
| ).pushNamedAndRemoveUntil(Routes.login, (_) => false); | |
| // debugPrint('User not authenticated, deeplink queued: $route'); | |
| } | |
| } catch (e) { | |
| // debugPrint('Auth check failed: $e'); | |
| _performNavigation(context, route, params, tabIndex: tabIndex); | |
| } | |
| } | |
| void _performNavigation( | |
| BuildContext context, | |
| String route, | |
| Map<String, String>? params, { | |
| int? tabIndex, | |
| }) { | |
| if (route == Routes.home) { | |
| Navigator.of(context).pushNamedAndRemoveUntil(route, (_) => false); | |
| if (tabIndex != null) { | |
| Future.delayed(const Duration(milliseconds: 500), () { | |
| final currentContext = Routes.navKey.currentContext; | |
| if (currentContext != null && currentContext.mounted) { | |
| TabNavigationService().switchToTab(tabIndex); | |
| } | |
| }); | |
| } | |
| } else if ([Routes.bankAccounts, Routes.profile].contains(route)) { | |
| Navigator.of(context).pushNamedAndRemoveUntil(Routes.home, (_) => false); | |
| Future.delayed(const Duration(milliseconds: 500), () { | |
| final ctx = Routes.navKey.currentContext; | |
| if (ctx != null && ctx.mounted) Navigator.of(ctx).pushNamed(route); | |
| }); | |
| } else { | |
| Navigator.of(context).pushNamedAndRemoveUntil(route, (_) => false); | |
| } | |
| } | |
| void _queueDeeplink( | |
| String route, | |
| Map<String, String>? params, { | |
| int? tabIndex, | |
| }) { | |
| _queuedDeeplink = _QueuedLink(route, params, tabIndex); | |
| debugPrint('Deeplink queued: $route'); | |
| } | |
| static void processQueuedDeeplinks(BuildContext context) { | |
| if (_queuedDeeplink == null) return; | |
| final queued = _queuedDeeplink!; | |
| _queuedDeeplink = null; // clear immediately | |
| DeeplinkService()._checkAuthAndNavigate( | |
| context, | |
| queued.route, | |
| queued.params, | |
| tabIndex: queued.tabIndex, | |
| ); | |
| } | |
| void dispose() { | |
| _subscription?.cancel(); | |
| _isInitialized = false; | |
| } | |
| } | |
| class _QueuedLink { | |
| final String route; | |
| final Map<String, String>? params; | |
| final int? tabIndex; | |
| _QueuedLink(this.route, this.params, this.tabIndex); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment