[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(notifs): push notification deep linking

+121 -12
+1 -1
.github/workflows/android-internal-release.yml
··· 44 44 dart run build_runner build --delete-conflicting-outputs 45 45 46 46 - name: Setup Firebase config 47 - run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > android/app/google-services.json 47 + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 -d > android/app/google-services.json 48 48 49 49 - name: Setup keys 50 50 uses: timheuer/base64-to-file@v1
+3 -3
ios/ci_scripts/ci_post_clone.sh
··· 25 25 EOL 26 26 27 27 # Decode Firebase config from base64 environment variable 28 - if [ -n "$GOOGLE_SERVICE_INFO_PLIST_BASE64" ]; then 28 + if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then 29 29 echo "Decoding GoogleService-Info.plist..." 30 - echo "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 -d > ios/Runner/GoogleService-Info.plist 30 + echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 -d > ios/Runner/GoogleService-Info.plist 31 31 else 32 - echo "Warning: GOOGLE_SERVICE_INFO_PLIST_BASE64 not set" 32 + echo "Warning: GOOGLE_SERVICE_INFO_PLIST not set" 33 33 fi 34 34 35 35 # Install CocoaPods using Homebrew.
+1 -1
lib/src/core/network/atproto/data/repositories/notification_repository_impl.dart
··· 129 129 headers: {'atproto-proxy': _client.sprkDid}, 130 130 ); 131 131 132 - // Clear the app badge locally (server also sends silent push for background) 132 + // Clear app badge locally (server also sends silent push for background) 133 133 try { 134 134 await GetIt.instance<PushNotificationService>().clearBadge(); 135 135 } catch (e) {
+91 -2
lib/src/core/notifications/push_notification_service.dart
··· 4 4 import 'package:firebase_core/firebase_core.dart'; 5 5 import 'package:firebase_messaging/firebase_messaging.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 + import 'package:spark/firebase_options.dart'; 8 + import 'package:spark/src/core/routing/app_router.dart'; 7 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 8 10 import 'package:spark/src/core/utils/logging/logger.dart'; 9 11 ··· 22 24 bool _badgeSupported = false; 23 25 bool _initialized = false; 24 26 27 + /// Queued notification data for cold start navigation 28 + /// This is set when the app is opened from terminated state via notification 29 + RemoteMessage? _pendingNotification; 30 + 25 31 /// Initializes Firebase without requesting permissions 26 - /// Permissions should be requested separately via [requestPermissionAndGetToken] 32 + /// Permissions should be requested via [requestPermissionAndGetToken] 27 33 Future<void> initialize() async { 28 34 _logger.i('Initializing push notification service'); 29 35 30 36 try { 31 - await Firebase.initializeApp(); 37 + await Firebase.initializeApp( 38 + options: DefaultFirebaseOptions.currentPlatform, 39 + ); 32 40 _messaging = FirebaseMessaging.instance; 33 41 _logger.d('Firebase initialized'); 34 42 ··· 41 49 42 50 _initialized = true; 43 51 _logger.i('Push notification service initialized successfully'); 52 + 53 + // Set up message handlers for deep linking 54 + await _setupMessageHandlers(); 44 55 } catch (e, stackTrace) { 45 56 _logger.e( 46 57 'Failed to initialize push notifications', 47 58 error: e, 48 59 stackTrace: stackTrace, 49 60 ); 61 + } 62 + } 63 + 64 + /// Sets up FCM message handlers for deep linking 65 + Future<void> _setupMessageHandlers() async { 66 + // Handle notification tap when app is in background 67 + FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); 68 + 69 + // Handle notification tap when app was terminated 70 + final initialMessage = await _messaging.getInitialMessage(); 71 + if (initialMessage != null) { 72 + _logger.i('App opened from terminated state via notification'); 73 + // Queue the navigation - will be processed after auth completes 74 + _pendingNotification = initialMessage; 75 + } 76 + 77 + // Handle foreground messages (for badge updates) 78 + FirebaseMessaging.onMessage.listen(_handleForegroundMessage); 79 + 80 + _logger.d('FCM message handlers set up'); 81 + } 82 + 83 + /// Handles notification tap when app is in background or foreground 84 + void _handleNotificationTap(RemoteMessage message) { 85 + _logger.i('Notification tapped: ${message.data}'); 86 + 87 + final data = message.data; 88 + final reason = data['reason'] as String?; 89 + final author = data['author'] as String?; 90 + final recordUri = data['recordUri'] as String?; 91 + final reasonSubject = data['reasonSubject'] as String?; 92 + 93 + if (!GetIt.instance.isRegistered<AppRouter>()) { 94 + _logger.w('AppRouter not registered, queueing navigation'); 95 + _pendingNotification = message; 96 + return; 97 + } 98 + 99 + final router = GetIt.instance<AppRouter>(); 100 + 101 + if (reason == 'follow' && author != null) { 102 + // Navigate to profile for follow notifications 103 + _logger.d('Navigating to profile: $author'); 104 + router.push(ProfileRoute(did: author)); 105 + } else if (reasonSubject != null) { 106 + // For likes/reposts, navigate to the subject (the post being liked/reposted) 107 + _logger.d('Navigating to post (reasonSubject): $reasonSubject'); 108 + router.push(StandalonePostRoute(postUri: reasonSubject)); 109 + } else if (recordUri != null) { 110 + // For replies/mentions, navigate to the record itself 111 + _logger.d('Navigating to post (recordUri): $recordUri'); 112 + router.push(StandalonePostRoute(postUri: recordUri)); 113 + } else if (author != null) { 114 + // Fallback to author profile 115 + _logger.d('Navigating to author profile (fallback): $author'); 116 + router.push(ProfileRoute(did: author)); 117 + } else { 118 + _logger.w('No valid navigation target in notification data'); 119 + } 120 + } 121 + 122 + /// Handles foreground messages (updates badge count) 123 + void _handleForegroundMessage(RemoteMessage message) { 124 + _logger.d('Foreground message received: ${message.notification?.title}'); 125 + 126 + // Badge is already set by the server in the APNS payload 127 + // We could optionally show an in-app notification here 128 + } 129 + 130 + /// Returns true if there's a pending notification navigation 131 + bool get hasPendingNotification => _pendingNotification != null; 132 + 133 + /// Processes pending notification navigation (call after auth completes) 134 + void processPendingNotification() { 135 + if (_pendingNotification != null) { 136 + _logger.i('Processing pending notification navigation'); 137 + _handleNotificationTap(_pendingNotification!); 138 + _pendingNotification = null; 50 139 } 51 140 } 52 141
+1 -1
lib/src/features/auth/providers/auth_providers.dart
··· 255 255 } 256 256 } 257 257 258 - /// Returns true if push registration is pending (permission not yet requested) 258 + /// True if push registration is pending (permission not yet requested) 259 259 bool get hasPendingPushRegistration => _pendingPushRegistration; 260 260 261 261 /// Requests push notification permission and registers if granted
+13 -4
lib/src/features/home/ui/pages/main_page.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter/services.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:get_it/get_it.dart'; 6 7 import 'package:spark/src/core/design_system/components/organisms/bottom_nav_bar.dart'; 8 + import 'package:spark/src/core/notifications/push_notification_service.dart'; 7 9 import 'package:spark/src/core/routing/app_router.dart'; 8 10 import 'package:spark/src/core/ui/theme/data/models/app_theme.dart'; 9 11 import 'package:spark/src/features/auth/providers/auth_providers.dart'; ··· 26 28 @override 27 29 void initState() { 28 30 super.initState(); 29 - // Request push notification permission if pending (after login) 31 + // Handle post-login tasks after the widget tree is built 30 32 WidgetsBinding.instance.addPostFrameCallback((_) { 31 - _requestPushPermissionIfNeeded(); 33 + _handlePostLoginTasks(); 32 34 }); 33 35 } 34 36 35 - /// Requests push notification permission if it was deferred during login 36 - Future<void> _requestPushPermissionIfNeeded() async { 37 + /// Handles tasks that need to run after login/auth completes 38 + Future<void> _handlePostLoginTasks() async { 39 + // Request push notification permission if pending (after login) 37 40 final auth = ref.read(authProvider.notifier); 38 41 if (auth.hasPendingPushRegistration) { 39 42 await auth.requestPushPermissionAndRegister(); 43 + } 44 + 45 + // Process any pending notification navigation (cold start) 46 + final pushService = GetIt.instance<PushNotificationService>(); 47 + if (pushService.hasPendingNotification) { 48 + pushService.processPendingNotification(); 40 49 } 41 50 } 42 51
+6
lib/src/features/notifications/ui/pages/notifications_page.dart
··· 21 21 Widget build(BuildContext context) { 22 22 final notificationState = ref.watch(notificationProvider()); 23 23 24 + // Reset the flag when refreshing so we can mark new notifications as seen 25 + if (notificationState.isRefreshing) { 26 + _hasMarkedAsSeen = false; 27 + } 28 + 24 29 // Mark notifications as seen once they're loaded 25 30 if (!_hasMarkedAsSeen && 26 31 !notificationState.isLoading && 32 + !notificationState.isRefreshing && 27 33 notificationState.notifications.isNotEmpty) { 28 34 _hasMarkedAsSeen = true; 29 35 WidgetsBinding.instance.addPostFrameCallback((_) {
+5
lib/src/sprk_app.dart
··· 25 25 @override 26 26 void initState() { 27 27 super.initState(); 28 + // Register AppRouter globally for push notification navigation 29 + if (!GetIt.instance.isRegistered<AppRouter>()) { 30 + GetIt.instance.registerSingleton<AppRouter>(_appRouter); 31 + } 32 + 28 33 ref.read(themeProvider.notifier).initialize(); 29 34 // Defer initialization to after the widget tree is built 30 35 // to avoid modifying providers during build