From b213d2b8b4578a5feb0a4e57f67664771353e94c Mon Sep 17 00:00:00 2001 From: Sylvester Menawar Date: Mon, 1 Jun 2026 06:08:37 +0100 Subject: [PATCH] feat(navigation): add deep linking support --- app.json | 20 +++++- package-lock.json | 15 ++++ package.json | 1 + src/navigation/AppNavigator.tsx | 16 ++++- src/navigation/linking.test.ts | 20 ++++++ src/navigation/linking.ts | 32 +++++++++ src/navigation/types.ts | 14 +++- src/screens/AddSubscriptionScreen.tsx | 54 +++++++++----- src/screens/NotFoundScreen.tsx | 74 +++++++++++++++++++ src/screens/SubscriptionDetailScreen.tsx | 32 ++++++++- src/store/subscriptionStore.ts | 78 +++++++++++--------- src/utils/deepLinkValidator.test.ts | 91 ++++++++++++++++++++++++ src/utils/deepLinkValidator.ts | 81 +++++++++++++++++++++ src/utils/shareLink.test.ts | 13 ++++ src/utils/shareLink.ts | 20 ++++++ 15 files changed, 500 insertions(+), 61 deletions(-) create mode 100644 src/navigation/linking.test.ts create mode 100644 src/navigation/linking.ts create mode 100644 src/screens/NotFoundScreen.tsx create mode 100644 src/utils/deepLinkValidator.test.ts create mode 100644 src/utils/deepLinkValidator.ts create mode 100644 src/utils/shareLink.test.ts create mode 100644 src/utils/shareLink.ts diff --git a/app.json b/app.json index 05445a4..246773b 100644 --- a/app.json +++ b/app.json @@ -3,6 +3,7 @@ "name": "SubTrackr", "slug": "subtrackr", "version": "1.0.0", + "scheme": "subtrackr", "orientation": "portrait", "icon": "./assets/subtrackr-icon.png", "userInterfaceStyle": "automatic", @@ -15,7 +16,8 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.subtrackr.app", - "icon": "./assets/subtrackr-icon.png" + "icon": "./assets/subtrackr-icon.png", + "associatedDomains": ["applinks:subtrackr.app"] }, "android": { "package": "com.subtrackr.app", @@ -24,7 +26,21 @@ "backgroundColor": "#1a1a1a" }, "edgeToEdgeEnabled": true, - "icon": "./assets/subtrackr-icon.png" + "icon": "./assets/subtrackr-icon.png", + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "subtrackr.app", + "pathPrefix": "/" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ] }, "web": { "favicon": "./assets/subtrackr-icon.png", diff --git a/package-lock.json b/package-lock.json index 6a533b8..64b52df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "expo-application": "~6.1.5", "expo-clipboard": "~7.1.5", "expo-dev-client": "~5.2.4", + "expo-linking": "~7.1.7", "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", "graphql": "^16.13.2", @@ -17081,6 +17082,20 @@ "react": "*" } }, + "node_modules/expo-linking": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.7.tgz", + "integrity": "sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA==", + "license": "MIT", + "dependencies": { + "expo-constants": "~17.1.7", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-manifests": { "version": "0.16.6", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz", diff --git a/package.json b/package.json index 9615bc8..72b165d 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "expo-application": "~6.1.5", "expo-clipboard": "~7.1.5", "expo-dev-client": "~5.2.4", + "expo-linking": "~7.1.7", "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", "graphql": "^16.13.2", diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6ef9207..65aa16d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { Text } from 'react-native'; +import { ActivityIndicator, Text, View } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { navigationRef } from './navigationRef'; +import { linkingConfig } from './linking'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useTranslation } from 'react-i18next'; @@ -20,7 +21,7 @@ import SlaDashboard from '../screens/SlaDashboard'; import GDPRSettingsScreen from '../screens/GDPRSettingsScreen'; import LanguageSettingsScreen from '../screens/LanguageSettingsScreen'; import SessionManagementScreen from '../screens/SessionManagementScreen'; -import SettingsScreen from '../screens/SettingsScreen'; +import { SettingsScreen } from '../screens/SettingsScreen'; import CalendarIntegrationScreen from '../screens/CalendarIntegrationScreen'; import AccountingExportScreen from '../screens/AccountingExportScreen'; import WebhookSettingsScreen from '../screens/WebhookSettingsScreen'; @@ -48,6 +49,7 @@ import ApiKeyManagementScreen from '../screens/ApiKeyManagementScreen'; import DocumentationPortalScreen from '../screens/DocumentationPortalScreen'; import IntegrationGuidesScreen from '../screens/IntegrationGuidesScreen'; import PerformanceDashboardScreen from '../screens/PerformanceDashboardScreen'; +import { NotFoundScreen } from '../screens/NotFoundScreen'; import { colors } from '../utils/constants'; import { RootStackParamList, TabParamList } from './types'; @@ -304,6 +306,7 @@ const SettingsStack = () => ( component={PerformanceDashboardScreen} options={{ title: 'Performance', headerShown: true }} /> + ); @@ -388,7 +391,14 @@ const TabNavigator = () => { export const AppNavigator = () => { return ( - + + + + }> ); diff --git a/src/navigation/linking.test.ts b/src/navigation/linking.test.ts new file mode 100644 index 0000000..dd86ca6 --- /dev/null +++ b/src/navigation/linking.test.ts @@ -0,0 +1,20 @@ +import { linkingConfig } from './linking'; + +jest.mock('expo-linking', () => ({ + createURL: (path: string) => `subtrackr://${path}`, +})); + +describe('linkingConfig', () => { + it('includes both the custom scheme and https prefixes', () => { + expect(linkingConfig.prefixes).toEqual( + expect.arrayContaining(['subtrackr://', 'https://subtrackr.app']) + ); + }); + + it('maps the subscription detail path to the correct screen', () => { + expect( + (linkingConfig.config?.screens as { HomeTab?: { screens?: { SubscriptionDetail?: string } } }) + ?.HomeTab?.screens?.SubscriptionDetail + ).toBe('subscription/:id'); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts new file mode 100644 index 0000000..b8602dc --- /dev/null +++ b/src/navigation/linking.ts @@ -0,0 +1,32 @@ +import * as Linking from 'expo-linking'; +import { LinkingOptions } from '@react-navigation/native'; + +import { TabParamList } from './types'; + +const prefix = Linking.createURL('/'); + +export const linkingConfig: LinkingOptions = { + prefixes: [prefix, 'subtrackr://', 'https://subtrackr.app'], + config: { + screens: { + HomeTab: { + screens: { + Home: '', + AddSubscription: { + path: 'subscription/add', + parse: { + amount: (amount: string) => Number.parseFloat(amount), + }, + }, + SubscriptionDetail: 'subscription/:id', + NotFound: '*', + }, + }, + SettingsTab: { + screens: { + Settings: 'settings', + }, + }, + }, + }, +}; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 4beeb89..61eee48 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,9 +1,16 @@ import { NavigatorScreenParams } from '@react-navigation/native'; +import { BillingCycle } from '../types/subscription'; export type RootStackParamList = { Home: undefined; - AddSubscription: undefined; - SubscriptionDetail: { id: string }; + AddSubscription: + | { + name?: string; + amount?: number; + cycle?: BillingCycle; + } + | undefined; + SubscriptionDetail: { id: string } | undefined; CancellationFlow: { subscriptionId: string }; WalletConnect: undefined; CryptoPayment: { subscriptionId?: string } | undefined; @@ -43,6 +50,7 @@ export type RootStackParamList = { LoyaltyDashboard: undefined; CampaignManagement: undefined; PerformanceDashboard: undefined; + NotFound: { reason?: string } | undefined; }; export type TabParamList = { @@ -51,5 +59,5 @@ export type TabParamList = { WalletTab: undefined; AnalyticsTab: undefined; RevenueTab: undefined; - SettingsTab: undefined; + SettingsTab: NavigatorScreenParams | undefined; }; diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index bd09d0d..b1549d2 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, @@ -11,7 +11,7 @@ import { KeyboardAvoidingView, Platform, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; import { useSubscriptionStore, useSettingsStore } from '../store'; @@ -23,30 +23,42 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { errorHandler } from '../services/errorHandler'; import type { SubscriptionFormData } from '../types/subscription'; import { BillingCycle, SubscriptionCategory } from '../types/subscription'; +import { validateAddSubscriptionParams } from '../utils/deepLinkValidator'; interface AddSubscriptionFormData extends SubscriptionFormData { priceError: string; } +type AddSubscriptionRouteProp = RouteProp; + +const buildInitialFormData = ( + preferredCurrency: string, + prefill: { name?: string; amount?: number; cycle?: BillingCycle } +): AddSubscriptionFormData => ({ + name: prefill.name ?? '', + description: '', + category: SubscriptionCategory.OTHER, + price: prefill.amount ?? 0, + priceError: '', + currency: preferredCurrency, + billingCycle: prefill.cycle ?? BillingCycle.MONTHLY, + nextBillingDate: new Date(), + notificationsEnabled: true, + isCryptoEnabled: false, + cryptoToken: undefined, + cryptoAmount: undefined, +}); + const AddSubscriptionScreen: React.FC = () => { const navigation = useNavigation>(); + const route = useRoute(); const { addSubscription, isLoading, error } = useSubscriptionStore(); const { preferredCurrency } = useSettingsStore(); + const validation = validateAddSubscriptionParams(route.params ?? {}); + const validationErrors = validation.errors; + const initialFormData = buildInitialFormData(preferredCurrency, validation.sanitised); - const [formData, setFormData] = useState({ - name: '', - description: '', - category: SubscriptionCategory.OTHER, - price: 0, - priceError: '', - currency: preferredCurrency, - billingCycle: BillingCycle.MONTHLY, - nextBillingDate: new Date(), - notificationsEnabled: true, - isCryptoEnabled: false, - cryptoToken: undefined, - cryptoAmount: undefined, - }); + const [formData, setFormData] = useState(initialFormData); useEffect(() => { if (error) { @@ -56,14 +68,20 @@ const AddSubscriptionScreen: React.FC = () => { } }, [error]); + useEffect(() => { + if (validationErrors.length > 0) { + console.warn('Invalid add subscription deep link params', validationErrors); + } + }, [validationErrors]); + // Date Picker States const [showPicker, setShowPicker] = useState(false); const [pickerMode, setPickerMode] = useState<'date' | 'time'>('date'); const [selectedCategory, setSelectedCategory] = useState( - SubscriptionCategory.OTHER + initialFormData.category ); const [selectedBillingCycle, setSelectedBillingCycle] = useState( - BillingCycle.MONTHLY + initialFormData.billingCycle ); const handleCategorySelect = (category: SubscriptionCategory) => { diff --git a/src/screens/NotFoundScreen.tsx b/src/screens/NotFoundScreen.tsx new file mode 100644 index 0000000..bdc105b --- /dev/null +++ b/src/screens/NotFoundScreen.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { RootStackParamList } from '../navigation/types'; +import { colors, spacing, typography } from '../utils/constants'; + +type NotFoundNavigationProp = NativeStackNavigationProp; + +export function NotFoundScreen() { + const navigation = useNavigation(); + const route = useRoute(); + const reason = (route.params as { reason?: string } | undefined)?.reason; + + return ( + + + Page not found + + The link could not be opened. Please check the URL and try again. + + {reason ? {reason} : null} + navigation.navigate('Home')} + accessibilityLabel="Go to home screen" + style={styles.button}> + Go to Home + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.sm, + textAlign: 'center', + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.md, + }, + reason: { + ...typography.body2, + color: colors.error, + textAlign: 'center', + marginBottom: spacing.lg, + }, + button: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderRadius: 10, + }, + buttonText: { + color: '#fff', + fontWeight: '700', + }, +}); diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index 885a029..14e5d0c 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -19,6 +19,8 @@ import { colors, spacing, typography } from '../utils/constants'; import { getCategoryIcon } from '../utils/subscriptionHelpers'; import { RootStackParamList } from '../navigation/types'; import { useGroupStore } from '../store/groupStore'; +import { shareSubscriptionLink } from '../utils/shareLink'; +import { validateSubscriptionId } from '../utils/deepLinkValidator'; // Components import { Button } from '../components/common/Button'; @@ -31,7 +33,8 @@ type NavigationProp = NativeStackNavigationProp; const SubscriptionDetailScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); - const { id } = route.params; + const validation = validateSubscriptionId(route.params?.id); + const id = route.params?.id; const { subscriptions, toggleSubscriptionStatus, updateSubscription, recordBillingOutcome } = useSubscriptionStore(); @@ -47,12 +50,28 @@ const SubscriptionDetailScreen: React.FC = () => { const [loading, setLoading] = useState(!subscription); + useEffect(() => { + if (!validation.isValid) { + navigation.replace('NotFound', { reason: validation.error }); + } + }, [navigation, validation.error, validation.isValid]); + useEffect(() => { if (subscription) { setLoading(false); } }, [subscription]); + const handleShare = useCallback(async () => { + if (!subscription) return; + + try { + await shareSubscriptionLink(subscription.id, subscription.name); + } catch (error) { + Alert.alert('Error', 'Failed to share subscription link'); + } + }, [subscription]); + const handlePauseResume = useCallback(async () => { if (!subscription) return; try { @@ -90,6 +109,10 @@ const SubscriptionDetailScreen: React.FC = () => { ); } + if (!validation.isValid) { + return null; + } + if (!subscription) { return ( @@ -264,6 +287,13 @@ const SubscriptionDetailScreen: React.FC = () => { Subscription Management +