feat: Expo scaffold + auth screens + core screens (#39-#46, #48)

This commit is contained in:
Nemo
2026-03-23 18:40:04 +08:00
commit 745321e5bd
45 changed files with 10557 additions and 0 deletions

49
components/AppButton.tsx Normal file
View 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
View 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
View 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
View 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>
);
};

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

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

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