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:
rckrdmrd 2026-01-18 04:44:36 -06:00
parent a71600df2f
commit 10f586e7e7
5 changed files with 691 additions and 0 deletions

View 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;

View 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;

View 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
View File

@ -0,0 +1,3 @@
export { ConnectionIndicator } from './ConnectionIndicator';
export { OfflineBanner } from './OfflineBanner';
export { SyncProgress } from './SyncProgress';

204
src/services/deepLinking.ts Normal file
View 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,
};