redesign take 1

This commit is contained in:
dimitar committed 2026-03-11 08:22:48 +01:00
1 parent 1143f8ca02
commit e3a3c3fccf
19 files changed
+2783 -1493

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import React from "react";
import { View, Text, StyleSheet, ViewStyle, StyleProp } from "react-native";
import { useTheme } from "../contexts/ThemeContext";
import { fontSize, fontWeight } from "../styles/typography";
type BadgeVariant =
| "neutral"
| "success"
| "warning"
| "danger"
| "info"
| "primary";
type BadgeSize = "sm" | "md";
interface BadgeProps {
label: string;
variant?: BadgeVariant;
size?: BadgeSize;
style?: StyleProp<ViewStyle>;
}
/**
* Badge - Pill-shaped status indicator
*
* Variants:
* - neutral: Gray badge for general labels
* - success: Green badge for positive status
* - warning: Orange/yellow badge for warnings
* - danger: Red badge for errors or critical status
* - info: Blue badge for informational status
* - primary: Primary color badge
*
* Sizes:
* - sm: 5px vertical, 10px horizontal, 11px font
* - md: 6px vertical, 12px horizontal, 13px font (default)
*/
export function Badge({
label,
variant = "neutral",
size = "md",
style,
}: BadgeProps) {
const { colors } = useTheme();
const sizeStyles = {
sm: {
paddingVertical: 5,
paddingHorizontal: 10,
fontSize: fontSize.xs,
},
md: {
paddingVertical: 6,
paddingHorizontal: 12,
fontSize: fontSize.sm,
},
};
const variantStyles: Record<
BadgeVariant,
{ backgroundColor: string; color: string }
> = {
neutral: {
backgroundColor: colors.surfaceElevated,
color: colors.textSecondary,
},
success: {
backgroundColor: `${colors.success}20`, // 20% opacity
color: colors.success,
},
warning: {
backgroundColor: `${colors.warning}20`,
color: colors.warning,
},
danger: {
backgroundColor: `${colors.danger}20`,
color: colors.danger,
},
info: {
backgroundColor: `${colors.info}20`,
color: colors.info,
},
primary: {
backgroundColor: `${colors.primary}20`,
color: colors.primary,
},
};
return (
<View
style={[
styles.badge,
{
backgroundColor: variantStyles[variant].backgroundColor,
paddingVertical: sizeStyles[size].paddingVertical,
paddingHorizontal: sizeStyles[size].paddingHorizontal,
},
style,
]}
>
<Text
style={[
styles.label,
{
color: variantStyles[variant].color,
fontSize: sizeStyles[size].fontSize,
fontWeight: fontWeight.medium,
},
]}
>
{label}
</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
borderRadius: 999, // Full pill shape
alignSelf: "flex-start",
},
label: {
textAlign: "center",
},
});
+119 -133
View File
@@ -1,143 +1,129 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { Animated } from 'react-native';
import React from "react";
import { View, StyleSheet, TouchableOpacity } from "react-native";
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "../contexts/ThemeContext";
const { width } = Dimensions.get('window');
/**
* CustomTabBar - Minimal bottom navigation with pill indicator
*
* Design:
* - Simple flat design (no floating, no glassmorphism)
* - Clean icons with outline/filled states
* - Small pill indicator below active tab
* - 56px height (reduced from 70px)
* - No animations (just opacity fade on press)
* - Theme-aware colors
*/
export function CustomTabBar({
state,
descriptors,
navigation,
}: BottomTabBarProps) {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={['rgba(255, 255, 255, 0.9)', 'rgba(255, 255, 255, 0.7)']}
style={[styles.tabBar, theme.shadows.medium]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const isFocused = state.index === index;
return (
<View
style={[
styles.container,
{
backgroundColor: colors.surface,
borderTopColor: colors.border,
paddingBottom: insets.bottom,
},
]}
>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
const onPress = () => {
const event = navigation.emit({
type: "tabPress",
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const getIconName = (routeName: string, focused: boolean): keyof typeof Ionicons.glyphMap => {
switch (routeName) {
case 'index':
return focused ? 'home' : 'home-outline';
case 'goals':
return focused ? 'trophy' : 'trophy-outline';
case 'attendance':
return focused ? 'calendar' : 'calendar-outline';
case 'recommendations':
return focused ? 'sparkles' : 'sparkles-outline';
case 'profile':
return focused ? 'person' : 'person-outline';
default:
return 'ellipse-outline';
}
};
const getIconName = (
routeName: string,
focused: boolean,
): keyof typeof Ionicons.glyphMap => {
switch (routeName) {
case "index":
return focused ? "home" : "home-outline";
case "goals":
return focused ? "trophy" : "trophy-outline";
case "attendance":
return focused ? "calendar" : "calendar-outline";
case "recommendations":
return focused ? "sparkles" : "sparkles-outline";
case "profile":
return focused ? "person" : "person-outline";
default:
return "ellipse-outline";
}
};
// Animation for scale
const scaleValue = React.useRef(new Animated.Value(1)).current;
React.useEffect(() => {
Animated.spring(scaleValue, {
toValue: isFocused ? 1.2 : 1,
useNativeDriver: true,
friction: 8,
}).start();
}, [isFocused]);
return (
<TouchableOpacity
key={index}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={(options as any).tabBarTestID}
onPress={onPress}
style={styles.tabItem}
activeOpacity={0.7}
>
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
{isFocused ? (
<LinearGradient
colors={theme.gradients.primary}
style={styles.iconContainer}
>
<Ionicons
name={getIconName(route.name, true)}
size={20}
color="#fff"
/>
</LinearGradient>
) : (
<Ionicons
name={getIconName(route.name, false)}
size={24}
color={theme.colors.gray500}
/>
)}
</Animated.View>
</TouchableOpacity>
);
})}
</LinearGradient>
</View>
);
return (
<TouchableOpacity
key={index}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={(options as any).tabBarTestID}
onPress={onPress}
style={styles.tabItem}
activeOpacity={0.7}
>
<View style={styles.iconWrapper}>
<Ionicons
name={getIconName(route.name, isFocused)}
size={24}
color={isFocused ? colors.primary : colors.textTertiary}
/>
{isFocused && (
<View
style={[
styles.indicator,
{ backgroundColor: colors.primary },
]}
/>
)}
</View>
</TouchableOpacity>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
paddingBottom: Platform.OS === 'ios' ? 30 : 20,
pointerEvents: 'box-none',
},
tabBar: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 35,
height: 70,
width: width - 40,
justifyContent: 'space-around',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
paddingHorizontal: 10,
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
container: {
flexDirection: "row",
height: 56,
borderTopWidth: 1,
},
tabItem: {
flex: 1,
alignItems: "center",
justifyContent: "center",
height: "100%",
},
iconWrapper: {
alignItems: "center",
justifyContent: "center",
},
indicator: {
width: 24,
height: 3,
borderRadius: 999,
marginTop: 4,
},
});
@@ -0,0 +1,85 @@
import React from "react";
import { View, StyleSheet, ViewStyle, StyleProp } from "react-native";
import { useTheme } from "../contexts/ThemeContext";
type IconContainerVariant = "plain" | "subtle" | "colored";
type IconContainerSize = "sm" | "md" | "lg";
interface IconContainerProps {
children: React.ReactNode;
variant?: IconContainerVariant;
size?: IconContainerSize;
backgroundColor?: string;
style?: StyleProp<ViewStyle>;
}
/**
* IconContainer - Clean container for icons with subtle backgrounds
*
* Variants:
* - plain: No background, just the icon
* - subtle: Light background (surfaceSecondary)
* - colored: Custom background color (pass backgroundColor prop)
*
* Sizes:
* - sm: 32px circle
* - md: 40px circle (default)
* - lg: 48px circle
*/
export function IconContainer({
children,
variant = "subtle",
size = "md",
backgroundColor,
style,
}: IconContainerProps) {
const { colors } = useTheme();
const sizeStyles: Record<IconContainerSize, ViewStyle> = {
sm: {
width: 32,
height: 32,
borderRadius: 16,
},
md: {
width: 40,
height: 40,
borderRadius: 20,
},
lg: {
width: 48,
height: 48,
borderRadius: 24,
},
};
const getBackgroundColor = (): string | undefined => {
if (variant === "colored" && backgroundColor) {
return backgroundColor;
}
if (variant === "subtle") {
return colors.surfaceElevated;
}
return "transparent";
};
return (
<View
style={[
styles.container,
sizeStyles[size],
{ backgroundColor: getBackgroundColor() },
style,
]}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
justifyContent: "center",
},
});
+95
View File
@@ -0,0 +1,95 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ViewStyle,
StyleProp,
} from "react-native";
import { useTheme } from "../contexts/ThemeContext";
interface ListItemProps {
title: string;
subtitle?: string;
leftIcon?: React.ReactNode;
rightElement?: React.ReactNode;
onPress?: () => void;
style?: StyleProp<ViewStyle>;
}
/**
* ListItem - Clean list item with optional icon and trailing element
*
* Usage:
* - Settings lists
* - Navigation items
* - Selectable options
*
* Features:
* - Optional left icon (wrap in IconContainer)
* - Optional subtitle for additional context
* - Optional right element (arrow, badge, switch, etc.)
* - Optional onPress for touchable items
*/
export function ListItem({
title,
subtitle,
leftIcon,
rightElement,
onPress,
style,
}: ListItemProps) {
const { colors, typography } = useTheme();
const content = (
<View style={[styles.container, style]}>
{leftIcon && <View style={styles.leftIcon}>{leftIcon}</View>}
<View style={styles.textContainer}>
<Text style={[typography.h3, { color: colors.textPrimary }]}>
{title}
</Text>
{subtitle && (
<Text
style={[
typography.caption,
{ color: colors.textTertiary, marginTop: 2 },
]}
>
{subtitle}
</Text>
)}
</View>
{rightElement && <View style={styles.rightElement}>{rightElement}</View>}
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{content}
</TouchableOpacity>
);
}
return content;
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: "transparent",
},
leftIcon: {
marginRight: 12,
},
textContainer: {
flex: 1,
},
rightElement: {
marginLeft: 12,
},
});
@@ -0,0 +1,152 @@
import React from "react";
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
StyleProp,
} from "react-native";
import { useTheme } from "../contexts/ThemeContext";
import { fontSize, fontWeight } from "../styles/typography";
type ButtonVariant = "primary" | "secondary" | "tertiary" | "danger";
type ButtonSize = "sm" | "md" | "lg";
interface MinimalButtonProps {
title: string;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
/**
* MinimalButton - Clean button component with solid colors
*
* Variants:
* - primary: Solid primary background
* - secondary: Outlined with primary color
* - tertiary: Text only, no background
* - danger: Solid danger background
*
* Sizes:
* - sm: 12px vertical padding, 16px horizontal
* - md: 14px vertical padding, 24px horizontal (default)
* - lg: 16px vertical padding, 32px horizontal
*/
export function MinimalButton({
title,
onPress,
variant = "primary",
size = "md",
loading = false,
disabled = false,
style,
textStyle,
}: MinimalButtonProps) {
const { colors } = useTheme();
const isDisabled = disabled || loading;
// Get button styles based on variant
const getButtonStyle = (): ViewStyle => {
const baseStyle: ViewStyle = {
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
opacity: isDisabled ? 0.5 : 1,
};
// Size-specific padding
const sizeStyles: Record<
ButtonSize,
{ paddingVertical: number; paddingHorizontal: number }
> = {
sm: { paddingVertical: 12, paddingHorizontal: 16 },
md: { paddingVertical: 14, paddingHorizontal: 24 },
lg: { paddingVertical: 16, paddingHorizontal: 32 },
};
const variantStyles: Record<ButtonVariant, ViewStyle> = {
primary: {
backgroundColor: colors.primary,
},
secondary: {
backgroundColor: "transparent",
borderWidth: 1.5,
borderColor: colors.primary,
},
tertiary: {
backgroundColor: "transparent",
},
danger: {
backgroundColor: colors.danger,
},
};
return {
...baseStyle,
...sizeStyles[size],
...variantStyles[variant],
};
};
// Get text styles based on variant
const getTextStyle = (): TextStyle => {
const baseTextStyle: TextStyle = {
fontSize: fontSize.base,
fontWeight: fontWeight.semibold,
};
const variantTextStyles: Record<ButtonVariant, TextStyle> = {
primary: {
color: colors.white,
},
secondary: {
color: colors.primary,
},
tertiary: {
color: colors.primary,
},
danger: {
color: colors.white,
},
};
return {
...baseTextStyle,
...variantTextStyles[variant],
};
};
return (
<TouchableOpacity
style={[getButtonStyle(), style]}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator
size="small"
color={
variant === "secondary" || variant === "tertiary"
? colors.primary
: colors.white
}
/>
) : (
<Text style={[getTextStyle(), textStyle]}>{title}</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
// No static styles needed - all dynamic based on theme
});
@@ -0,0 +1,84 @@
import React from "react";
import {
View,
StyleSheet,
TouchableOpacity,
ViewStyle,
StyleProp,
} from "react-native";
import { useTheme } from "../contexts/ThemeContext";
type CardVariant = "default" | "elevated" | "bordered";
interface MinimalCardProps {
children: React.ReactNode;
variant?: CardVariant;
onPress?: () => void;
style?: StyleProp<ViewStyle>;
}
/**
* MinimalCard - Clean card component without gradients
*
* Variants:
* - default: Subtle shadow on surface background
* - elevated: More prominent shadow
* - bordered: Border instead of shadow
*/
export function MinimalCard({
children,
variant = "default",
onPress,
style,
}: MinimalCardProps) {
const { colors } = useTheme();
const cardStyles = [
styles.base,
{
backgroundColor: colors.surface,
},
variant === "default" && styles.default,
variant === "elevated" && styles.elevated,
variant === "bordered" && {
borderWidth: 1,
borderColor: colors.border,
},
style,
];
if (onPress) {
return (
<TouchableOpacity
style={cardStyles}
onPress={onPress}
activeOpacity={0.7}
>
{children}
</TouchableOpacity>
);
}
return <View style={cardStyles}>{children}</View>;
}
const styles = StyleSheet.create({
base: {
borderRadius: 12,
padding: 16,
},
default: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 1,
},
elevated: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
},
});
@@ -0,0 +1,82 @@
import React from "react";
import { View, StyleSheet, ViewStyle, StyleProp } from "react-native";
import { useTheme } from "../contexts/ThemeContext";
interface ProgressBarProps {
progress: number; // 0-1 (e.g., 0.75 for 75%)
color?: string;
backgroundColor?: string;
height?: number;
borderRadius?: number;
style?: StyleProp<ViewStyle>;
}
/**
* ProgressBar - Simple linear progress indicator
*
* Usage:
* - Goal progress tracking
* - Loading states
* - Completion indicators
*
* Props:
* - progress: Value between 0 and 1 (e.g., 0.75 for 75%)
* - color: Custom fill color (defaults to theme primary)
* - backgroundColor: Custom track color (defaults to theme border)
* - height: Bar height in pixels (defaults to 8)
* - borderRadius: Corner radius (defaults to 999 for full pill shape)
*/
export function ProgressBar({
progress,
color,
backgroundColor,
height = 8,
borderRadius = 999,
style,
}: ProgressBarProps) {
const { colors } = useTheme();
// Clamp progress between 0 and 1
const clampedProgress = Math.min(Math.max(progress, 0), 1);
const trackColor = backgroundColor || colors.border;
const fillColor = color || colors.primary;
return (
<View
style={[
styles.track,
{
height,
borderRadius,
backgroundColor: trackColor,
},
style,
]}
>
<View
style={[
styles.fill,
{
width: `${clampedProgress * 100}%`,
height,
borderRadius,
backgroundColor: fillColor,
},
]}
/>
</View>
);
}
const styles = StyleSheet.create({
track: {
width: "100%",
overflow: "hidden",
},
fill: {
position: "absolute",
left: 0,
top: 0,
},
});
@@ -0,0 +1,80 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ViewStyle,
StyleProp,
} from "react-native";
import { useTheme } from "../contexts/ThemeContext";
interface SectionHeaderProps {
title: string;
subtitle?: string;
actionLabel?: string;
onActionPress?: () => void;
style?: StyleProp<ViewStyle>;
}
/**
* SectionHeader - Clean section header with optional action button
*
* Usage:
* - Divides content into logical sections
* - Optional subtitle for additional context
* - Optional action button (e.g., "See All", "Add New")
*/
export function SectionHeader({
title,
subtitle,
actionLabel,
onActionPress,
style,
}: SectionHeaderProps) {
const { colors, typography } = useTheme();
return (
<View style={[styles.container, style]}>
<View style={styles.textContainer}>
<Text style={[typography.h2, { color: colors.textPrimary }]}>
{title}
</Text>
{subtitle && (
<Text
style={[
typography.caption,
{ color: colors.textTertiary, marginTop: 2 },
]}
>
{subtitle}
</Text>
)}
</View>
{actionLabel && onActionPress && (
<TouchableOpacity onPress={onActionPress} activeOpacity={0.7}>
<Text
style={[
typography.body,
{ color: colors.primary, fontWeight: "600" },
]}
>
{actionLabel}
</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
},
textContainer: {
flex: 1,
},
});