Mobile App design
Run Tests on Branches / Detect Changes (push) Successful in 10s
Run Tests on Branches / Backend Tests (push) Successful in 2m12s
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m47s

This commit is contained in:
Ichitux
2026-07-09 13:33:54 +02:00
parent 5f604b11ba
commit 2f36ef685d
32 changed files with 1793 additions and 926 deletions
@@ -2,7 +2,8 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
import { useThemeContext } from './ThemeProvider';
import { spacing, borderRadius } from '../constants/theme';
interface BarcodeScannerProps {
onBarcodeScanned: (barcode: string) => void;
@@ -12,6 +13,7 @@ interface BarcodeScannerProps {
export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const { colors } = useThemeContext();
if (!permission) {
return <View style={styles.container} />;
@@ -19,17 +21,17 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
if (!permission.granted) {
return (
<View style={styles.permissionContainer}>
<View style={[styles.permissionContainer, { backgroundColor: colors.background }]}>
<Ionicons name="camera" size={64} color={colors.textSecondary} />
<Text style={styles.permissionTitle}>Permiso de cámara requerido</Text>
<Text style={styles.permissionText}>
<Text style={[styles.permissionTitle, { color: colors.text }]}>Permiso de cámara requerido</Text>
<Text style={[styles.permissionText, { color: colors.textSecondary }]}>
Necesitamos acceso a la cámara para escanear códigos de barras
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<TouchableOpacity style={[styles.permissionButton, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={styles.permissionButtonText}>Conceder permiso</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
<Text style={styles.cancelButtonText}>Cancelar</Text>
<Text style={[styles.cancelButtonText, { color: colors.textSecondary }]}>Cancelar</Text>
</TouchableOpacity>
</View>
);
@@ -54,10 +56,10 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
<View style={styles.overlay}>
<View style={styles.scannerFrame}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
<View style={[styles.corner, styles.topLeft, { borderColor: colors.primary }]} />
<View style={[styles.corner, styles.topRight, { borderColor: colors.primary }]} />
<View style={[styles.corner, styles.bottomLeft, { borderColor: colors.primary }]} />
<View style={[styles.corner, styles.bottomRight, { borderColor: colors.primary }]} />
</View>
<Text style={styles.instruction}>
@@ -72,7 +74,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
{scanned && (
<View style={styles.scannedOverlay}>
<TouchableOpacity
style={styles.scanAgainButton}
style={[styles.scanAgainButton, { backgroundColor: colors.primary }]}
onPress={() => setScanned(false)}
>
<Text style={styles.scanAgainText}>Escanear de nuevo</Text>
@@ -92,30 +94,26 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
padding: spacing.xl,
},
permissionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.lg,
},
permissionText: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.sm,
marginBottom: spacing.xl,
},
permissionButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
},
permissionButtonText: {
color: colors.textInverse,
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
@@ -123,7 +121,6 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
cancelButtonText: {
color: colors.textSecondary,
fontSize: 14,
},
overlay: {
@@ -143,7 +140,6 @@ const styles = StyleSheet.create({
position: 'absolute',
width: 30,
height: 30,
borderColor: colors.primary,
},
topLeft: {
top: 0,
@@ -170,7 +166,7 @@ const styles = StyleSheet.create({
borderRightWidth: 3,
},
instruction: {
color: colors.textInverse,
color: '#ffffff',
fontSize: 14,
textAlign: 'center',
marginTop: spacing.xl,
@@ -194,13 +190,12 @@ const styles = StyleSheet.create({
right: spacing.xl,
},
scanAgainButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
},
scanAgainText: {
color: colors.textInverse,
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
@@ -1,16 +1,19 @@
import React from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { colors, spacing } from '../constants/theme';
import { useThemeContext } from './ThemeProvider';
import { spacing } from '../constants/theme';
interface LoadingSpinnerProps {
message?: string;
}
export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) {
const { colors } = useThemeContext();
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.message}>{message}</Text>
<Text style={[styles.message, { color: colors.textSecondary }]}>{message}</Text>
</View>
);
}
@@ -25,6 +28,5 @@ const styles = StyleSheet.create({
message: {
marginTop: spacing.md,
fontSize: 16,
color: colors.textSecondary,
},
});
@@ -2,7 +2,8 @@ import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
import { useThemeContext } from './ThemeProvider';
import { spacing, borderRadius } from '../constants/theme';
import { StockBadge } from './StockBadge';
import { Medicine } from '../types';
@@ -16,6 +17,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) {
const router = useRouter();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
const handlePress = () => {
router.push(`/medicine/${medicine.nregistro}`);
@@ -23,32 +25,32 @@ export function MedicineCard({ medicine }: MedicineCardProps) {
return (
<TouchableOpacity
style={[styles.card, isTablet && styles.cardTablet]}
style={[styles.card, isTablet && styles.cardTablet, { backgroundColor: colors.card }]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={styles.header}>
<Text style={[styles.name, isTablet && styles.nameTablet]} numberOfLines={2}>
<Text style={[styles.name, isTablet && styles.nameTablet, { color: colors.text }]} numberOfLines={2}>
{medicine.name}
</Text>
{medicine.stock != null && <StockBadge stock={medicine.stock} />}
</View>
<Text style={styles.principioActivo} numberOfLines={1}>
<Text style={[styles.principioActivo, { color: colors.textSecondary }]} numberOfLines={1}>
{medicine.active_ingredient}{medicine.dosage ? ` - ${medicine.dosage}` : ''}
</Text>
<View style={styles.footer}>
<View style={styles.priceContainer}>
<Ionicons name="pricetag" size={14} color={colors.textSecondary} />
<Text style={[styles.price, isTablet && styles.priceTablet]}>
<Text style={[styles.price, isTablet && styles.priceTablet, { color: colors.primary }]}>
{medicine.precio != null ? `${medicine.precio.toFixed(2)}` : 'Sin precio'}
</Text>
</View>
<View style={styles.labContainer}>
<Ionicons name="business" size={14} color={colors.textSecondary} />
<Text style={[styles.laboratorio, isTablet && styles.laboratorioTablet]} numberOfLines={1}>
<Text style={[styles.laboratorio, isTablet && styles.laboratorioTablet, { color: colors.textSecondary }]} numberOfLines={1}>
{medicine.laboratory}
</Text>
</View>
@@ -59,7 +61,6 @@ export function MedicineCard({ medicine }: MedicineCardProps) {
const styles = StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginHorizontal: spacing.lg,
@@ -86,7 +87,6 @@ const styles = StyleSheet.create({
flex: 1,
fontSize: 16,
fontWeight: '600',
color: colors.text,
marginRight: spacing.sm,
},
nameTablet: {
@@ -94,7 +94,6 @@ const styles = StyleSheet.create({
},
principioActivo: {
fontSize: 14,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
footer: {
@@ -109,7 +108,6 @@ const styles = StyleSheet.create({
price: {
fontSize: 14,
fontWeight: '600',
color: colors.primary,
marginLeft: spacing.xs,
},
priceTablet: {
@@ -123,7 +121,6 @@ const styles = StyleSheet.create({
},
laboratorio: {
fontSize: 12,
color: colors.textSecondary,
marginLeft: spacing.xs,
maxWidth: 120,
},
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { View, TextInput, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../constants/theme';
import { useThemeContext } from './ThemeProvider';
import { spacing, borderRadius } from '../constants/theme';
const TABLET_MIN_WIDTH = 768;
@@ -20,6 +21,7 @@ export function SearchBar({
}: SearchBarProps) {
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
const [localValue, setLocalValue] = useState(value || '');
const handleChange = (text: string) => {
@@ -38,10 +40,10 @@ export function SearchBar({
};
return (
<View style={[styles.container, isTablet && styles.containerTablet]}>
<View style={[styles.container, isTablet && styles.containerTablet, { backgroundColor: colors.card }]}>
<Ionicons name="search" size={20} color={colors.textSecondary} style={styles.icon} />
<TextInput
style={[styles.input, isTablet && styles.inputTablet]}
style={[styles.input, isTablet && styles.inputTablet, { color: colors.text }]}
placeholder={placeholder}
placeholderTextColor={colors.textSecondary}
value={localValue}
@@ -63,15 +65,12 @@ const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
maxWidth: 420,
alignSelf: 'center',
width: '100%',
width: '80%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
@@ -88,7 +87,6 @@ const styles = StyleSheet.create({
input: {
flex: 1,
fontSize: 16,
color: colors.text,
paddingVertical: spacing.xs,
},
inputTablet: {
+17 -21
View File
@@ -1,27 +1,32 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, borderRadius, spacing } from '../constants/theme';
import { useThemeContext } from './ThemeProvider';
import { borderRadius, spacing } from '../constants/theme';
interface StockBadgeProps {
stock: number;
}
export function StockBadge({ stock }: StockBadgeProps) {
const getBadgeStyle = () => {
if (stock === 0) return styles.danger;
if (stock < 5) return styles.warning;
return styles.success;
const { colors, isDark } = useThemeContext();
const getBadgeColors = () => {
if (stock === 0) {
return { bg: isDark ? '#3a1a1a' : '#feecec', text: isDark ? '#ef9a9a' : '#b91c1c' };
}
if (stock < 5) {
return { bg: isDark ? '#3a3010' : '#fff3cd', text: isDark ? '#ffd54f' : '#FF9500' };
}
return { bg: isDark ? '#1a3a1c' : '#eaf7ec', text: isDark ? '#81c784' : '#34C759' };
};
const getText = () => {
if (stock === 0) return 'Sin stock';
if (stock < 5) return `Bajo (${stock})`;
return `Disponible (${stock})`;
};
const badgeColors = getBadgeColors();
return (
<View style={[styles.badge, getBadgeStyle()]}>
<Text style={styles.text}>{getText()}</Text>
<View style={[styles.badge, { backgroundColor: badgeColors.bg }]}>
<Text style={[styles.text, { color: badgeColors.text }]}>
{stock === 0 ? 'Sin stock' : stock < 5 ? `Bajo (${stock})` : `Disponible (${stock})`}
</Text>
</View>
);
}
@@ -32,15 +37,6 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
},
success: {
backgroundColor: '#eaf7ec',
},
warning: {
backgroundColor: '#fff3cd',
},
danger: {
backgroundColor: '#feecec',
},
text: {
fontSize: 12,
fontWeight: '600',
@@ -0,0 +1,43 @@
import React, { createContext, useContext, useEffect, useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { useThemeStore } from '../store/themeStore';
import { colors, darkColors } from '../constants/theme';
import type { AppColors } from '../hooks/useThemeColor';
interface ThemeContextValue {
colors: AppColors;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextValue>({
colors,
isDark: false,
});
export function useThemeContext() {
return useContext(ThemeContext);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme();
const mode = useThemeStore((s) => s.mode);
const init = useThemeStore((s) => s.init);
useEffect(() => {
init();
}, []);
const value = useMemo(() => {
const isDark = mode === 'dark' || (mode === 'system' && systemScheme === 'dark');
return {
colors: isDark ? darkColors : colors,
isDark,
};
}, [mode, systemScheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}