feat(MCH-022,MCH-025): Add offline UI components and deep linking
- ConnectionIndicator: Visual indicator for online/offline/syncing status - OfflineBanner: Animated banner shown when offline - SyncProgress: Modal showing detailed sync status and pending operations - deepLinking: Complete deep linking service with URL scheme michangarrito:// Sprint 7 completion - Modo Offline UI + Widgets/Atajos Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a71600df2f
commit
10f586e7e7
77
src/components/ConnectionIndicator.tsx
Normal file
77
src/components/ConnectionIndicator.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useOfflineSyncContext } from '../contexts/OfflineSyncContext';
|
||||
|
||||
interface ConnectionIndicatorProps {
|
||||
onPress?: () => void;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionIndicator({ onPress, showLabel = false }: ConnectionIndicatorProps) {
|
||||
const { isOnline, isSyncing, pendingSalesCount } = useOfflineSyncContext();
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!isOnline) return '#EF4444'; // Red - offline
|
||||
if (isSyncing || pendingSalesCount > 0) return '#F59E0B'; // Yellow - syncing/pending
|
||||
return '#10B981'; // Green - online
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!isOnline) return 'Sin conexion';
|
||||
if (isSyncing) return 'Sincronizando...';
|
||||
if (pendingSalesCount > 0) return `${pendingSalesCount} pendiente${pendingSalesCount > 1 ? 's' : ''}`;
|
||||
return 'Conectado';
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.indicator, { backgroundColor: getStatusColor() }]}>
|
||||
{isSyncing && <View style={styles.pulseRing} />}
|
||||
</View>
|
||||
{showLabel && (
|
||||
<Text style={[styles.label, { color: getStatusColor() }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
indicator: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
position: 'relative',
|
||||
},
|
||||
pulseRing: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.3)',
|
||||
top: -4,
|
||||
left: -4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ConnectionIndicator;
|
||||
123
src/components/OfflineBanner.tsx
Normal file
123
src/components/OfflineBanner.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, TouchableOpacity } from 'react-native';
|
||||
import { useOfflineSyncContext } from '../contexts/OfflineSyncContext';
|
||||
|
||||
interface OfflineBannerProps {
|
||||
onSyncPress?: () => void;
|
||||
}
|
||||
|
||||
export function OfflineBanner({ onSyncPress }: OfflineBannerProps) {
|
||||
const { isOnline, isSyncing, pendingSalesCount, error } = useOfflineSyncContext();
|
||||
const slideAnim = useRef(new Animated.Value(-60)).current;
|
||||
|
||||
const shouldShow = !isOnline || error;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: shouldShow ? 0 : -60,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [shouldShow, slideAnim]);
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const getMessage = () => {
|
||||
if (error) return error;
|
||||
if (!isOnline && pendingSalesCount > 0) {
|
||||
return `Modo offline - ${pendingSalesCount} venta${pendingSalesCount > 1 ? 's' : ''} pendiente${pendingSalesCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
return 'Modo offline - los cambios se sincronizaran cuando vuelva la conexion';
|
||||
};
|
||||
|
||||
const getBannerStyle = () => {
|
||||
if (error) return styles.errorBanner;
|
||||
return styles.offlineBanner;
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
getBannerStyle(),
|
||||
{ transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.icon}>{error ? '!' : '!'}</Text>
|
||||
<Text style={styles.message} numberOfLines={2}>
|
||||
{getMessage()}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOnline && pendingSalesCount > 0 && onSyncPress && (
|
||||
<TouchableOpacity
|
||||
style={styles.syncButton}
|
||||
onPress={onSyncPress}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<Text style={styles.syncButtonText}>
|
||||
{isSyncing ? 'Sincronizando...' : 'Sincronizar'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
zIndex: 1000,
|
||||
},
|
||||
offlineBanner: {
|
||||
backgroundColor: '#F59E0B',
|
||||
},
|
||||
errorBanner: {
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
width: 24,
|
||||
height: 24,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
syncButton: {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
syncButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default OfflineBanner;
|
||||
284
src/components/SyncProgress.tsx
Normal file
284
src/components/SyncProgress.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useOfflineSyncContext } from '../contexts/OfflineSyncContext';
|
||||
|
||||
interface SyncProgressProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SyncProgress({ visible, onClose }: SyncProgressProps) {
|
||||
const {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
pendingSalesCount,
|
||||
lastSyncAt,
|
||||
error,
|
||||
syncNow,
|
||||
downloadDataForOffline,
|
||||
} = useOfflineSyncContext();
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return 'Nunca';
|
||||
return date.toLocaleString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.modal}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Estado de Sincronizacion</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeText}>X</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusCard}>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Conexion:</Text>
|
||||
<View style={styles.statusValue}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: isOnline ? '#10B981' : '#EF4444' },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.statusText}>
|
||||
{isOnline ? 'Conectado' : 'Sin conexion'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Ultima sincronizacion:</Text>
|
||||
<Text style={styles.statusText}>{formatDate(lastSyncAt)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Ventas pendientes:</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
pendingSalesCount > 0 && styles.pendingText,
|
||||
]}
|
||||
>
|
||||
{pendingSalesCount}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorCard}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isSyncing && (
|
||||
<View style={styles.syncingContainer}>
|
||||
<ActivityIndicator size="large" color="#F97316" />
|
||||
<Text style={styles.syncingText}>Sincronizando datos...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.downloadButton]}
|
||||
onPress={downloadDataForOffline}
|
||||
disabled={isSyncing || !isOnline}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isSyncing ? 'Descargando...' : 'Descargar datos'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{pendingSalesCount > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.syncButton]}
|
||||
onPress={syncNow}
|
||||
disabled={isSyncing || !isOnline}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isSyncing ? 'Sincronizando...' : `Sincronizar (${pendingSalesCount})`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoTitle}>Datos disponibles offline:</Text>
|
||||
<View style={styles.infoList}>
|
||||
<Text style={styles.infoItem}>• Productos y precios</Text>
|
||||
<Text style={styles.infoItem}>• Clientes</Text>
|
||||
<Text style={styles.infoItem}>• Ventas en efectivo</Text>
|
||||
</View>
|
||||
<Text style={styles.infoNote}>
|
||||
Las ventas con tarjeta requieren conexion a internet.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
},
|
||||
statusCard: {
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
statusValue: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
pendingText: {
|
||||
color: '#F59E0B',
|
||||
},
|
||||
errorCard: {
|
||||
margin: 16,
|
||||
marginTop: 0,
|
||||
padding: 12,
|
||||
backgroundColor: '#FEF2F2',
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#EF4444',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
color: '#B91C1C',
|
||||
},
|
||||
syncingContainer: {
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
syncingText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
actions: {
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
downloadButton: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
syncButton: {
|
||||
backgroundColor: '#F97316',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
infoSection: {
|
||||
padding: 16,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoList: {
|
||||
gap: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoItem: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
},
|
||||
infoNote: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default SyncProgress;
|
||||
3
src/components/index.ts
Normal file
3
src/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ConnectionIndicator } from './ConnectionIndicator';
|
||||
export { OfflineBanner } from './OfflineBanner';
|
||||
export { SyncProgress } from './SyncProgress';
|
||||
204
src/services/deepLinking.ts
Normal file
204
src/services/deepLinking.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import { NavigationContainerRef } from '@react-navigation/native';
|
||||
|
||||
// URL scheme prefix
|
||||
const SCHEME = 'michangarrito://';
|
||||
|
||||
// Supported deep link routes
|
||||
export const DEEP_LINK_ROUTES = {
|
||||
// Main screens
|
||||
DASHBOARD: 'dashboard',
|
||||
POS: 'pos',
|
||||
POS_NEW: 'pos/new',
|
||||
PRODUCTS: 'products',
|
||||
PRODUCT_DETAIL: 'products/:id',
|
||||
INVENTORY: 'inventory',
|
||||
ORDERS: 'orders',
|
||||
ORDER_DETAIL: 'orders/:id',
|
||||
CUSTOMERS: 'customers',
|
||||
SCAN: 'scan',
|
||||
SETTINGS: 'settings',
|
||||
REPORTS: 'reports',
|
||||
};
|
||||
|
||||
// Map deep link paths to screen names
|
||||
const ROUTE_MAP: Record<string, { screen: string; nested?: string }> = {
|
||||
dashboard: { screen: 'Main', nested: 'Dashboard' },
|
||||
pos: { screen: 'Main', nested: 'POS' },
|
||||
'pos/new': { screen: 'Main', nested: 'POS' },
|
||||
products: { screen: 'Main', nested: 'Products' },
|
||||
inventory: { screen: 'Main', nested: 'Inventory' },
|
||||
orders: { screen: 'Main', nested: 'Orders' },
|
||||
customers: { screen: 'Main', nested: 'Customers' },
|
||||
scan: { screen: 'Scanner' },
|
||||
settings: { screen: 'Main', nested: 'Settings' },
|
||||
reports: { screen: 'Main', nested: 'Reports' },
|
||||
};
|
||||
|
||||
// Navigation reference (set from App.tsx)
|
||||
let navigationRef: NavigationContainerRef<any> | null = null;
|
||||
|
||||
export function setNavigationRef(ref: NavigationContainerRef<any>) {
|
||||
navigationRef = ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linking configuration for React Navigation
|
||||
*/
|
||||
export function getLinkingConfig() {
|
||||
return {
|
||||
prefixes: [SCHEME, 'https://michangarrito.com'],
|
||||
config: {
|
||||
screens: {
|
||||
Main: {
|
||||
screens: {
|
||||
Dashboard: 'dashboard',
|
||||
POS: 'pos',
|
||||
Products: 'products',
|
||||
Inventory: 'inventory',
|
||||
Orders: 'orders',
|
||||
Customers: 'customers',
|
||||
Settings: 'settings',
|
||||
Reports: 'reports',
|
||||
},
|
||||
},
|
||||
Scanner: 'scan',
|
||||
ProductDetail: 'products/:id',
|
||||
OrderDetail: 'orders/:id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a deep link URL and return the route info
|
||||
*/
|
||||
export function parseDeepLink(url: string): {
|
||||
path: string;
|
||||
params?: Record<string, string>;
|
||||
} | null {
|
||||
try {
|
||||
const parsed = Linking.parse(url);
|
||||
|
||||
if (!parsed.path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = parsed.path;
|
||||
const params = parsed.queryParams as Record<string, string> || {};
|
||||
|
||||
// Check for path parameters (e.g., products/:id)
|
||||
const pathParts = path.split('/');
|
||||
if (pathParts.length >= 2) {
|
||||
const basePath = pathParts[0];
|
||||
const id = pathParts[1];
|
||||
if (id && !id.startsWith(':')) {
|
||||
params.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
return { path, params };
|
||||
} catch (error) {
|
||||
console.error('Error parsing deep link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a deep link destination
|
||||
*/
|
||||
export async function navigateToDeepLink(url: string) {
|
||||
if (!navigationRef?.isReady()) {
|
||||
console.warn('Navigation not ready for deep link:', url);
|
||||
// Store for later processing
|
||||
pendingDeepLink = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDeepLink(url);
|
||||
if (!parsed) {
|
||||
console.warn('Could not parse deep link:', url);
|
||||
return;
|
||||
}
|
||||
|
||||
const routeInfo = ROUTE_MAP[parsed.path] || ROUTE_MAP[parsed.path.split('/')[0]];
|
||||
if (!routeInfo) {
|
||||
console.warn('Unknown deep link route:', parsed.path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (routeInfo.nested) {
|
||||
navigationRef.navigate(routeInfo.screen, {
|
||||
screen: routeInfo.nested,
|
||||
params: parsed.params,
|
||||
});
|
||||
} else {
|
||||
navigationRef.navigate(routeInfo.screen, parsed.params);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error navigating to deep link:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Store pending deep link for cold start
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
/**
|
||||
* Process any pending deep link (called when navigation is ready)
|
||||
*/
|
||||
export function processPendingDeepLink() {
|
||||
if (pendingDeepLink && navigationRef?.isReady()) {
|
||||
navigateToDeepLink(pendingDeepLink);
|
||||
pendingDeepLink = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initial deep link URL (for cold start)
|
||||
*/
|
||||
export async function getInitialDeepLink(): Promise<string | null> {
|
||||
const url = await Linking.getInitialURL();
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to deep link events
|
||||
*/
|
||||
export function subscribeToDeepLinks(
|
||||
callback: (url: string) => void
|
||||
): () => void {
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => {
|
||||
callback(url);
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deep link URL for a given path
|
||||
*/
|
||||
export function createDeepLink(path: string, params?: Record<string, string>): string {
|
||||
let url = `${SCHEME}${path}`;
|
||||
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export default {
|
||||
setNavigationRef,
|
||||
getLinkingConfig,
|
||||
parseDeepLink,
|
||||
navigateToDeepLink,
|
||||
processPendingDeepLink,
|
||||
getInitialDeepLink,
|
||||
subscribeToDeepLinks,
|
||||
createDeepLink,
|
||||
DEEP_LINK_ROUTES,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user