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 9bef9fa..5c5e1e2 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", @@ -17082,6 +17083,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 88d4200..18e5c27 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "expo-image": "~2.3.0", "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 104dc8c..49e8009 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'; @@ -21,7 +22,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'; 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 a98a498..3633d64 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,5 @@ import { NavigatorScreenParams } from '@react-navigation/native'; +import { BillingCycle } from '../types/subscription'; export type RootStackParamList = { Home: undefined; @@ -53,5 +54,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 812dc26..c30c4ca 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -12,7 +12,7 @@ import { Platform, Keyboard, } 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'; @@ -25,6 +25,7 @@ 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; @@ -38,6 +39,9 @@ const AddSubscriptionScreen: React.FC = () => { const styles = React.useMemo(() => createStyles(colors), [colors]); const { addSubscription, isLoading, error } = useSubscriptionStore(); const { preferredCurrency } = useSettingsStore(); + const validation = validateAddSubscriptionParams(route.params ?? {}); + const validationErrors = validation.errors; + const initialFormData = buildInitialFormData(preferredCurrency, validation.sanitised); // Ref for the name input — used for delayed focus instead of autoFocus, // so the screen has time to fully render before the keyboard opens. @@ -97,10 +101,10 @@ const AddSubscriptionScreen: React.FC = () => { 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 1ed32d5..601baf7 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,6 +50,12 @@ 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); @@ -94,6 +103,10 @@ const SubscriptionDetailScreen: React.FC = () => { ); } + if (!validation.isValid) { + return null; + } + if (!subscription) { return ( @@ -275,6 +288,13 @@ const SubscriptionDetailScreen: React.FC = () => { Subscription Management +