feat: Expo scaffold + auth screens + core screens (#39-#46, #48)
This commit is contained in:
49
components/AppButton.tsx
Normal file
49
components/AppButton.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, Text, ActivityIndicator, View } from 'react-native';
|
||||
|
||||
interface AppButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'outline';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AppButton: React.FC<AppButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const baseClasses = 'flex-row items-center justify-center rounded-xl px-6 py-3.5 active:opacity-80';
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600',
|
||||
secondary: 'bg-gray-200',
|
||||
danger: 'bg-red-600',
|
||||
outline: 'border-2 border-blue-600 bg-transparent',
|
||||
};
|
||||
const textClasses = {
|
||||
primary: 'text-white font-semibold text-base',
|
||||
secondary: 'text-gray-800 font-semibold text-base',
|
||||
danger: 'text-white font-semibold text-base',
|
||||
outline: 'text-blue-600 font-semibold text-base',
|
||||
};
|
||||
const disabledClass = disabled || loading ? 'opacity-50' : '';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${disabledClass} ${className}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'outline' ? '#2563EB' : '#fff'} size="small" />
|
||||
) : (
|
||||
<Text className={textClasses[variant]}>{title}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
31
components/AppCard.tsx
Normal file
31
components/AppCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { View, ViewProps } from 'react-native';
|
||||
|
||||
interface AppCardProps extends ViewProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none';
|
||||
}
|
||||
|
||||
export const AppCard: React.FC<AppCardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
...props
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-white rounded-2xl shadow-sm border border-gray-100 ${paddingClasses[padding]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
48
components/AppInput.tsx
Normal file
48
components/AppInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Text, TouchableOpacity, TextInputProps } from 'react-native';
|
||||
|
||||
interface AppInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
secureToggle?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppInput: React.FC<AppInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
secureToggle,
|
||||
leftIcon,
|
||||
...props
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<View className="mb-4">
|
||||
{label && (
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1.5">{label}</Text>
|
||||
)}
|
||||
<View
|
||||
className={`flex-row items-center bg-white border rounded-xl px-4 h-12 ${
|
||||
error ? 'border-red-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{leftIcon && <View className="mr-2">{leftIcon}</View>}
|
||||
<TextInput
|
||||
className="flex-1 text-gray-900 text-base"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
secureTextEntry={secureToggle ? !showPassword : props.secureTextEntry}
|
||||
{...props}
|
||||
/>
|
||||
{secureToggle && (
|
||||
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
|
||||
<Text className="text-blue-600 text-sm font-medium">
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{error && <Text className="text-red-500 text-xs mt-1">{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
55
components/Avatar.tsx
Normal file
55
components/Avatar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image } from 'react-native';
|
||||
|
||||
interface AvatarProps {
|
||||
name?: string;
|
||||
uri?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { container: 'w-8 h-8', text: 'text-xs' },
|
||||
md: { container: 'w-10 h-10', text: 'text-sm' },
|
||||
lg: { container: 'w-14 h-14', text: 'text-xl' },
|
||||
xl: { container: 'w-20 h-20', text: 'text-3xl' },
|
||||
};
|
||||
|
||||
const sizePx = { sm: 32, md: 40, lg: 56, xl: 80 };
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function hashColor(name: string) {
|
||||
const colors = ['bg-blue-500', 'bg-purple-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500', 'bg-teal-500'];
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff;
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<AvatarProps> = ({ name = '', uri, size = 'md' }) => {
|
||||
const { container, text } = sizeClasses[size];
|
||||
const px = sizePx[size];
|
||||
|
||||
if (uri) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
className={`${container} rounded-full`}
|
||||
style={{ width: px, height: px, borderRadius: px / 2 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`${container} ${hashColor(name)} rounded-full items-center justify-center`}
|
||||
style={{ width: px, height: px, borderRadius: px / 2 }}>
|
||||
<Text className={`${text} font-bold text-white`}>{getInitials(name) || '?'}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
57
components/ConfirmModal.tsx
Normal file
57
components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Modal, View, Text, TouchableOpacity } from 'react-native';
|
||||
import { AppButton } from './AppButton';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmVariant?: 'primary' | 'danger';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
confirmVariant = 'primary',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50 items-center justify-center px-6"
|
||||
activeOpacity={1}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="w-full bg-white rounded-2xl p-6 shadow-lg"
|
||||
activeOpacity={1}
|
||||
>
|
||||
<Text className="text-xl font-bold text-gray-900 mb-2">{title}</Text>
|
||||
<Text className="text-gray-600 text-sm mb-6">{message}</Text>
|
||||
<View className="flex-row gap-3">
|
||||
<AppButton
|
||||
title={cancelLabel}
|
||||
onPress={onCancel}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
/>
|
||||
<AppButton
|
||||
title={confirmLabel}
|
||||
onPress={onConfirm}
|
||||
variant={confirmVariant}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
32
components/EmptyState.tsx
Normal file
32
components/EmptyState.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { AppButton } from './AppButton';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
icon = '📭',
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8 py-16">
|
||||
<Text className="text-5xl mb-4">{icon}</Text>
|
||||
<Text className="text-lg font-semibold text-gray-800 text-center mb-2">{title}</Text>
|
||||
{description && (
|
||||
<Text className="text-sm text-gray-500 text-center mb-6">{description}</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<AppButton title={actionLabel} onPress={onAction} className="min-w-[160px]" />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
34
components/LoadingSpinner.tsx
Normal file
34
components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator, Text } from 'react-native';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
fullScreen?: boolean;
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message,
|
||||
fullScreen = false,
|
||||
size = 'large',
|
||||
}) => {
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-50">
|
||||
<ActivityIndicator size={size} color="#2563EB" />
|
||||
{message && (
|
||||
<Text className="mt-3 text-gray-500 text-sm">{message}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center py-8">
|
||||
<ActivityIndicator size={size} color="#2563EB" />
|
||||
{message && (
|
||||
<Text className="mt-2 text-gray-500 text-sm">{message}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
47
components/StatusBadge.tsx
Normal file
47
components/StatusBadge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
type Status =
|
||||
| 'active' | 'inactive' | 'suspended'
|
||||
| 'open' | 'in_progress' | 'resolved' | 'closed'
|
||||
| 'paid' | 'unpaid' | 'partial' | 'overdue'
|
||||
| 'pending' | 'approved' | 'rejected'
|
||||
| string;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string }> = {
|
||||
active: { bg: 'bg-green-100', text: 'text-green-700', label: 'Active' },
|
||||
inactive: { bg: 'bg-gray-100', text: 'text-gray-600', label: 'Inactive' },
|
||||
suspended: { bg: 'bg-red-100', text: 'text-red-700', label: 'Suspended' },
|
||||
open: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Open' },
|
||||
in_progress: { bg: 'bg-yellow-100',text: 'text-yellow-700',label: 'In Progress' },
|
||||
resolved: { bg: 'bg-green-100', text: 'text-green-700', label: 'Resolved' },
|
||||
closed: { bg: 'bg-gray-100', text: 'text-gray-600', label: 'Closed' },
|
||||
paid: { bg: 'bg-green-100', text: 'text-green-700', label: 'Paid' },
|
||||
unpaid: { bg: 'bg-red-100', text: 'text-red-700', label: 'Unpaid' },
|
||||
partial: { bg: 'bg-orange-100',text: 'text-orange-700',label: 'Partial' },
|
||||
overdue: { bg: 'bg-red-100', text: 'text-red-700', label: 'Overdue' },
|
||||
pending: { bg: 'bg-yellow-100',text: 'text-yellow-700',label: 'Pending' },
|
||||
approved: { bg: 'bg-green-100', text: 'text-green-700', label: 'Approved' },
|
||||
rejected: { bg: 'bg-red-100', text: 'text-red-700', label: 'Rejected' },
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, label }) => {
|
||||
const config = statusConfig[status] ?? {
|
||||
bg: 'bg-gray-100',
|
||||
text: 'text-gray-600',
|
||||
label: status.replace(/_/g, ' '),
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`px-2.5 py-1 rounded-full self-start ${config.bg}`}>
|
||||
<Text className={`text-xs font-medium capitalize ${config.text}`}>
|
||||
{label ?? config.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
8
components/index.ts
Normal file
8
components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { AppButton } from './AppButton';
|
||||
export { AppInput } from './AppInput';
|
||||
export { AppCard } from './AppCard';
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { Avatar } from './Avatar';
|
||||
export { ConfirmModal } from './ConfirmModal';
|
||||
Reference in New Issue
Block a user