Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "SubTrackr",
"slug": "subtrackr",
"version": "1.0.0",
"scheme": "subtrackr",
"orientation": "portrait",
"icon": "./assets/subtrackr-icon.png",
"userInterfaceStyle": "automatic",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -304,6 +306,7 @@ const SettingsStack = () => (
component={PerformanceDashboardScreen}
options={{ title: 'Performance', headerShown: true }}
/>
<Stack.Screen name="NotFound" component={NotFoundScreen} options={{ headerShown: false }} />
</Stack.Navigator>
);

Expand Down Expand Up @@ -388,7 +391,14 @@ const TabNavigator = () => {

export const AppNavigator = () => {
return (
<NavigationContainer ref={navigationRef}>
<NavigationContainer
ref={navigationRef}
linking={linkingConfig}
fallback={
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
}>
<TabNavigator />
</NavigationContainer>
);
Expand Down
20 changes: 20 additions & 0 deletions src/navigation/linking.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
32 changes: 32 additions & 0 deletions src/navigation/linking.ts
Original file line number Diff line number Diff line change
@@ -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<TabParamList> = {
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',
},
},
},
},
};
14 changes: 11 additions & 3 deletions src/navigation/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -43,6 +50,7 @@ export type RootStackParamList = {
LoyaltyDashboard: undefined;
CampaignManagement: undefined;
PerformanceDashboard: undefined;
NotFound: { reason?: string } | undefined;
};

export type TabParamList = {
Expand All @@ -51,5 +59,5 @@ export type TabParamList = {
WalletTab: undefined;
AnalyticsTab: undefined;
RevenueTab: undefined;
SettingsTab: undefined;
SettingsTab: NavigatorScreenParams<RootStackParamList> | undefined;
};
54 changes: 36 additions & 18 deletions src/screens/AddSubscriptionScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import {
View,
Text,
Expand All @@ -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';
Expand All @@ -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<RootStackParamList, 'AddSubscription'>;

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<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<AddSubscriptionRouteProp>();
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<AddSubscriptionFormData>({
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<AddSubscriptionFormData>(initialFormData);

useEffect(() => {
if (error) {
Expand All @@ -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>(
SubscriptionCategory.OTHER
initialFormData.category
);
const [selectedBillingCycle, setSelectedBillingCycle] = useState<BillingCycle>(
BillingCycle.MONTHLY
initialFormData.billingCycle
);

const handleCategorySelect = (category: SubscriptionCategory) => {
Expand Down
74 changes: 74 additions & 0 deletions src/screens/NotFoundScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<RootStackParamList>;

export function NotFoundScreen() {
const navigation = useNavigation<NotFoundNavigationProp>();
const route = useRoute();
const reason = (route.params as { reason?: string } | undefined)?.reason;

return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Page not found</Text>
<Text style={styles.subtitle}>
The link could not be opened. Please check the URL and try again.
</Text>
{reason ? <Text style={styles.reason}>{reason}</Text> : null}
<TouchableOpacity
onPress={() => navigation.navigate('Home')}
accessibilityLabel="Go to home screen"
style={styles.button}>
<Text style={styles.buttonText}>Go to Home</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}

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',
},
});
Loading