diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index f1ddbc5..acfe75f 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 477f759..320fb1d 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -23,6 +23,7 @@ "expo-constants": "^18.0.10", "expo-crypto": "^15.0.7", "expo-font": "~14.0.9", + "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", "expo-notifications": "~0.32.0", @@ -36,6 +37,7 @@ "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.0", "react-native-web": "^0.21.2", "zod": "^3.22.0" }, @@ -5596,6 +5598,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -6268,6 +6276,56 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -6493,6 +6551,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -6580,6 +6693,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -7135,6 +7260,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.7.tgz", + "integrity": "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "15.0.7", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", @@ -9878,6 +10012,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -10475,6 +10615,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -11496,6 +11648,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz", + "integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-url-polyfill": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2b487b5..8f5c5d5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -29,6 +29,7 @@ "expo-constants": "^18.0.10", "expo-crypto": "^15.0.7", "expo-font": "~14.0.9", + "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", "expo-notifications": "~0.32.0", @@ -42,6 +43,7 @@ "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.0", "react-native-web": "^0.21.2", "zod": "^3.22.0" }, diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 4c9aa08..e8697a6 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ import { Tabs, useRouter, useSegments } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; import { useAuth } from "@clerk/clerk-expo"; import { useEffect } from "react"; +import { CustomTabBar } from "../../components/CustomTabBar"; export default function TabLayout() { const { isSignedIn, isLoaded } = useAuth(); @@ -25,56 +25,40 @@ export default function TabLayout() { return ( } screenOptions={{ - tabBarActiveTintColor: "#2563eb", - tabBarInactiveTintColor: "#6b7280", - headerShown: true, - headerStyle: { - backgroundColor: "#fff", - }, - headerTitleStyle: { - fontWeight: "600", - }, + headerShown: false, // We'll use custom headers in screens or layout + tabBarShowLabel: false, }} > ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), }} /> ( - - ), + }} + /> + + + diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index b141fce..a264f63 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -1,117 +1,81 @@ -import { useState, useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - ActivityIndicator, - RefreshControl, - Alert, - Animated, -} from "react-native"; -import { useAuth } from "@clerk/clerk-expo"; -import { Ionicons } from "@expo/vector-icons"; -import { LinearGradient } from "expo-linear-gradient"; -import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from "../../services/fitnessGoals"; -import { GoalProgressCard } from "../../components/GoalProgressCard"; -import { GoalCreationModal } from "../../components/GoalCreationModal"; -import { theme } from "../../styles/theme"; +import React, { useState, useCallback, useRef } from 'react'; +import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { theme } from '../../styles/theme'; +import { GoalProgressCard } from '../../components/GoalProgressCard'; +import { GoalCreationModal } from '../../components/GoalCreationModal'; +import { useUser, useAuth } from "@clerk/clerk-expo"; +import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals'; +import { useFocusEffect } from 'expo-router'; export default function GoalsScreen() { - const { userId, getToken } = useAuth(); + const { user } = useUser(); + const { getToken } = useAuth(); const [goals, setGoals] = useState([]); - const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [showCreateModal, setShowCreateModal] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); const fabScale = useRef(new Animated.Value(1)).current; - const fetchGoals = async () => { - if (!userId) return; + const loadGoals = useCallback(async () => { + if (!user?.id) return; try { const token = await getToken(); - const data = await fitnessGoalsService.getGoals(userId, token); - setGoals(data); + const loadedGoals = await fitnessGoalsService.getGoals(user.id, token); + setGoals(loadedGoals); } catch (error) { - console.error("Error fetching fitness goals:", error); - Alert.alert("Error", "Failed to load goals. Please try again."); - } finally { - setLoading(false); - setRefreshing(false); + console.error('Error loading goals:', error); } - }; + }, [user?.id]); // Removed getToken from dependencies - useEffect(() => { - fetchGoals(); - }, [userId]); + useFocusEffect( + useCallback(() => { + loadGoals(); + }, [loadGoals]) + ); - const onRefresh = () => { + const onRefresh = async () => { setRefreshing(true); - fetchGoals(); + await loadGoals(); + setRefreshing(false); }; - const handleCreateGoal = async (goalData: CreateGoalData) => { - try { - const token = await getToken(); - const newGoal = await fitnessGoalsService.createGoal(goalData, token); - setGoals(prev => [newGoal, ...prev]); - Alert.alert("Success", "Goal created successfully!"); - } catch (error) { - console.error("Error creating goal:", error); - throw error; - } + const handleCreateGoal = async (newGoal: CreateGoalData) => { + const token = await getToken(); + await fitnessGoalsService.createGoal(newGoal, token); + await loadGoals(); + setIsModalVisible(false); }; - const handleCompleteGoal = async (goalId: string) => { - try { - const token = await getToken(); - const updatedGoal = await fitnessGoalsService.completeGoal(goalId, token); - setGoals(prev => prev.map(g => g.id === goalId ? updatedGoal : g)); - Alert.alert("Success", "Goal completed! 🎉"); - } catch (error) { - console.error("Error completing goal:", error); - Alert.alert("Error", "Failed to complete goal. Please try again."); - } + const handleCompleteGoal = async (goal: FitnessGoal) => { + const token = await getToken(); + await fitnessGoalsService.completeGoal(goal.id, token); + await loadGoals(); }; const handleDeleteGoal = async (goalId: string) => { - try { - const token = await getToken(); - await fitnessGoalsService.deleteGoal(goalId, token); - setGoals(prev => prev.filter(g => g.id !== goalId)); - Alert.alert("Success", "Goal deleted"); - } catch (error) { - console.error("Error deleting goal:", error); - Alert.alert("Error", "Failed to delete goal. Please try again."); - } + const token = await getToken(); + await fitnessGoalsService.deleteGoal(goalId, token); + await loadGoals(); }; const activeGoals = goals.filter(g => g.status === 'active'); const completedGoals = goals.filter(g => g.status === 'completed'); - if (loading && !refreshing) { - return ( - - - - ); - } - return ( + } > - My Fitness Goals + Fitness Goals Track your fitness journey progress @@ -133,7 +97,7 @@ export default function GoalsScreen() { {activeGoals.length > 0 ? Math.round( - activeGoals.reduce((sum, g) => sum + g.progress, 0) / + activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / activeGoals.length ) : 0}% @@ -161,7 +125,7 @@ export default function GoalsScreen() { handleCompleteGoal(goal.id)} + onComplete={() => handleCompleteGoal(goal)} onDelete={() => handleDeleteGoal(goal.id)} /> )) @@ -188,9 +152,9 @@ export default function GoalsScreen() { {/* Floating Action Button */} - + setShowCreateModal(true)} + onPress={() => setIsModalVisible(true)} onPressIn={() => { Animated.spring(fabScale, { toValue: 0.9, @@ -222,8 +186,8 @@ export default function GoalsScreen() { {/* Create Goal Modal */} setShowCreateModal(false)} + visible={isModalVisible} + onClose={() => setIsModalVisible(false)} onSubmit={handleCreateGoal} /> @@ -235,10 +199,8 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: theme.colors.background, }, - center: { - flex: 1, - justifyContent: "center", - alignItems: "center", + scrollContent: { + paddingBottom: 20, }, header: { padding: 24, @@ -268,7 +230,7 @@ const styles = StyleSheet.create({ backgroundColor: theme.colors.white, padding: 16, borderRadius: theme.borderRadius.xl, - alignItems: "center", + alignItems: 'center', ...theme.shadows.medium, borderWidth: 1, borderColor: "rgba(59, 130, 246, 0.1)", @@ -312,10 +274,12 @@ const styles = StyleSheet.create({ footer: { height: 100, }, - fab: { + fabContainer: { position: "absolute", right: 20, - bottom: 20, + bottom: 110, // Adjusted for tab bar height + }, + fab: { width: 64, height: 64, borderRadius: 32, diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index b905e83..9cb174d 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,239 +1,118 @@ -import React, { useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Animated, -} from "react-native"; +import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native"; import { useUser } from "@clerk/clerk-expo"; -import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; import { LinearGradient } from "expo-linear-gradient"; +import { useState, useCallback } from "react"; import { theme } from "../../styles/theme"; +import { ActivityWidget } from "../../components/ActivityWidget"; +import { QuickActionGrid } from "../../components/QuickActionGrid"; +import { Ionicons } from "@expo/vector-icons"; export default function HomeScreen() { - const { user, isLoaded } = useUser(); - const router = useRouter(); - const fadeAnim = useRef(new Animated.Value(0)).current; + const { user } = useUser(); + const [refreshing, setRefreshing] = useState(false); - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }).start(); + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => { + setRefreshing(false); + }, 2000); }, []); - if (!isLoaded || !user) { - return ( - - Loading... - - ); - } - - const firstName = user.firstName || "User"; - const greeting = getGreeting(); + const getGreeting = () => { + const hour = new Date().getHours(); + if (hour < 12) return "Good Morning"; + if (hour < 18) return "Good Afternoon"; + return "Good Evening"; + }; return ( - - - {/* Gradient Header */} - - {greeting}! - {firstName} - - - {/* Quick Stats with Glassmorphism */} - - - - - - - - 0 - This Month - - - - - - - - - 0 - Day Streak - - - - - - - - - 0 - Total Visits - - - - {/* Quick Actions */} - - Quick Actions - - router.push("/fitness-profile")} - activeOpacity={0.7} - > - - - - - Fitness Profile - - Manage your fitness information - - - - - - - - - - - Check In - - Start your workout session - - - - - - - - - - - View Schedule - - Check your upcoming classes - - - - - - - - - - - Payments - View payment history - - - - - - {/* Membership Info with Gradient */} - - Membership - - - Basic Plan - - Active + + + } + > + {/* Header Section */} + + + {getGreeting()}, + {user?.firstName || "Athlete"} + + + {user?.imageUrl ? ( + + ) : ( + + - - - {user.primaryEmailAddress?.emailAddress} - - - Member since {new Date(user.createdAt!).toLocaleDateString()} - - - - - {/* Recent Activity */} - - Recent Activity - - - - - - - No recent activity - - Check in to start tracking your workouts - + )} - - - ); -} -function getGreeting(): string { - const hour = new Date().getHours(); - if (hour < 12) return "Good morning"; - if (hour < 18) return "Good afternoon"; - return "Good evening"; + {/* Activity Widget */} + + + {/* Quick Actions */} + + + {/* Recent Activity Section */} + + + Recent Activity + See All + + + + + + + + + + + Upper Body Power + Today, 10:00 AM + + 45m + + + + + + + + + + Morning Cardio + Yesterday, 7:30 AM + + 30m + + + + + {/* Bottom Spacer for Tab Bar */} + + + + ); } const styles = StyleSheet.create({ @@ -241,167 +120,110 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: theme.colors.background, }, - content: { - paddingBottom: 20, + scrollContent: { + paddingTop: 60, }, header: { - padding: 24, - paddingTop: 60, - paddingBottom: 32, - borderBottomLeftRadius: theme.borderRadius['2xl'], - borderBottomRightRadius: theme.borderRadius['2xl'], - marginBottom: 20, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 24, + marginBottom: 32, }, greeting: { - fontSize: theme.typography.fontSize.base, - color: "rgba(255, 255, 255, 0.9)", + fontSize: 16, + color: theme.colors.gray600, + fontWeight: "500", marginBottom: 4, }, name: { - fontSize: theme.typography.fontSize['4xl'], - fontWeight: theme.typography.fontWeight.bold, - color: theme.colors.white, - }, - statsContainer: { - flexDirection: "row", - justifyContent: "space-between", - paddingHorizontal: 16, - marginBottom: 24, - gap: 12, - }, - statCard: { - flex: 1, - backgroundColor: theme.colors.white, - borderRadius: theme.borderRadius.xl, - padding: 16, - alignItems: "center", - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.3)", - }, - statIconContainer: { - marginBottom: 8, - }, - statIconGradient: { - width: 48, - height: 48, - borderRadius: 24, - justifyContent: "center", - alignItems: "center", - }, - statValue: { - fontSize: theme.typography.fontSize['2xl'], - fontWeight: theme.typography.fontWeight.bold, + fontSize: 32, + fontWeight: "800", color: theme.colors.gray900, - marginTop: 4, - marginBottom: 4, + letterSpacing: -0.5, }, - statLabel: { - fontSize: theme.typography.fontSize.xs, - color: theme.colors.gray600, - textAlign: "center", - fontWeight: theme.typography.fontWeight.medium, + avatarContainer: { + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 5, }, - section: { - marginBottom: 24, - paddingHorizontal: 20, - }, - sectionTitle: { - fontSize: theme.typography.fontSize.xl, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.gray900, - marginBottom: 12, - }, - actionButton: { - flexDirection: "row", - alignItems: "center", - backgroundColor: theme.colors.white, - borderRadius: theme.borderRadius.xl, - padding: 16, - marginBottom: 12, - }, - actionIcon: { + avatar: { width: 56, height: 56, - borderRadius: 28, + borderRadius: 20, + borderWidth: 2, + borderColor: "#fff", + }, + placeholderAvatar: { + width: 56, + height: 56, + borderRadius: 20, + backgroundColor: theme.colors.primary, justifyContent: "center", alignItems: "center", - marginRight: 12, + borderWidth: 2, + borderColor: "#fff", }, - actionContent: { - flex: 1, + section: { + paddingHorizontal: 20, + marginBottom: 24, }, - actionTitle: { - fontSize: theme.typography.fontSize.base, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.gray900, - marginBottom: 2, - }, - actionSubtitle: { - fontSize: theme.typography.fontSize.sm, - color: theme.colors.gray600, - }, - membershipCard: { - borderRadius: theme.borderRadius.xl, - padding: 20, - }, - membershipHeader: { + sectionHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - marginBottom: 12, - }, - membershipType: { - fontSize: theme.typography.fontSize.xl, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.white, - }, - statusBadge: { - backgroundColor: "rgba(255, 255, 255, 0.25)", - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.3)", - }, - statusText: { - fontSize: theme.typography.fontSize.xs, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.white, - }, - membershipEmail: { - fontSize: theme.typography.fontSize.sm, - color: "rgba(255, 255, 255, 0.9)", - marginBottom: 4, - }, - membershipDate: { - fontSize: theme.typography.fontSize.xs, - color: "rgba(255, 255, 255, 0.7)", - }, - emptyState: { - backgroundColor: theme.colors.white, - borderRadius: theme.borderRadius.xl, - padding: 32, - alignItems: "center", - }, - emptyIconContainer: { marginBottom: 16, }, - emptyIconGradient: { - width: 96, - height: 96, - borderRadius: 48, + sectionTitle: { + fontSize: 18, + fontWeight: "700", + color: theme.colors.gray900, + }, + seeAll: { + fontSize: 14, + color: theme.colors.primary, + fontWeight: "600", + }, + activityCard: { + gap: 12, + }, + recentItem: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderRadius: 20, + backgroundColor: "#fff", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.6)", + }, + recentIconContainer: { + marginRight: 16, + }, + recentIcon: { + width: 48, + height: 48, + borderRadius: 16, justifyContent: "center", alignItems: "center", }, - emptyStateText: { - fontSize: theme.typography.fontSize.base, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.gray700, + recentInfo: { + flex: 1, + }, + recentTitle: { + fontSize: 16, + fontWeight: "600", + color: theme.colors.gray900, marginBottom: 4, }, - emptyStateSubtext: { - fontSize: theme.typography.fontSize.sm, + recentSubtitle: { + fontSize: 12, color: theme.colors.gray500, - textAlign: "center", + }, + recentValue: { + fontSize: 14, + fontWeight: "600", + color: theme.colors.gray900, }, }); diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index 9e096c1..36c6f89 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -1,235 +1,135 @@ -import React from "react"; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - Alert, - ScrollView, -} from "react-native"; -import { useUser, useAuth } from "@clerk/clerk-expo"; -import { useRouter } from "expo-router"; +import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native"; +import { useUser, useClerk } from "@clerk/clerk-expo"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { theme } from "../../styles/theme"; +import { AnimatedButton } from "../../components/AnimatedButton"; +import { GradientBackground } from "../../components/GradientBackground"; export default function ProfileScreen() { const { user } = useUser(); - const { signOut } = useAuth(); - const router = useRouter(); + const { signOut } = useClerk(); - const handleLogout = async () => { - Alert.alert("Sign Out", "Are you sure you want to sign out?", [ - { text: "Cancel", style: "cancel" }, - { - text: "Sign Out", - style: "destructive", - onPress: async () => { - try { - await signOut(); - router.replace("/(auth)/sign-in"); - } catch (error) { - Alert.alert("Error", "Failed to sign out"); - } - }, - }, - ]); + const handleSignOut = async () => { + try { + await signOut(); + } catch (err) { + console.error("Error signing out:", err); + } }; - if (!user) { - return ( - - Loading... - + const confirmSignOut = () => { + Alert.alert( + "Sign Out", + "Are you sure you want to sign out?", + [ + { text: "Cancel", style: "cancel" }, + { text: "Sign Out", style: "destructive", onPress: handleSignOut }, + ] ); - } + }; return ( - - - {/* Profile Header with Gradient */} - + + + - {user.imageUrl ? ( - - - {user.firstName?.charAt(0)} - {user.lastName?.charAt(0)} - - + {user?.imageUrl ? ( + ) : ( - + + + )} + + + + + {user?.fullName || "User"} + {user?.primaryEmailAddress?.emailAddress} + + Premium Member + + + + + + + Account + + + + - )} - - - - {user.firstName} {user.lastName} - - - {user.primaryEmailAddress?.emailAddress} - - - {user.primaryPhoneNumber && ( - - {user.primaryPhoneNumber.phoneNumber} - - )} - - - {/* Account Information */} - - Account Information - - - - - - - - Email - - - {user.primaryEmailAddress?.emailAddress} - - - - {user.primaryPhoneNumber && ( - - - - - - Phone - - - {user.primaryPhoneNumber.phoneNumber} - - - )} - - - - - - - Member Since - - - {new Date(user.createdAt!).toLocaleDateString()} - - - - - - - - - Email Verified - - - {user.primaryEmailAddress?.verification?.status === "verified" - ? "Yes" - : "No"} - - + Personal Details + + + + + + + + Fitness Profile + + + + + + + + Notifications + + - {/* Quick Actions */} - Quick Actions - - - - - - Edit Profile - - - - - - - - Notifications - - - - - - - - Payment History - - - - - - - - Settings - - + Support + + + + + + Help Center + + + + + + + + Privacy & Security + + + - {/* Sign Out Button */} - - - - Sign Out - - + } + /> - {/* App Version */} Version 1.0.0 - + ); } @@ -238,134 +138,130 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: theme.colors.background, }, - content: { - padding: 20, + header: { paddingTop: 60, + paddingBottom: 30, + borderBottomLeftRadius: theme.borderRadius.xl, + borderBottomRightRadius: theme.borderRadius.xl, + alignItems: 'center', }, profileCard: { - borderRadius: theme.borderRadius.xl, - padding: 28, - alignItems: "center", - marginBottom: 24, + alignItems: 'center', }, avatarContainer: { + position: 'relative', marginBottom: 16, }, avatar: { - width: 96, - height: 96, - borderRadius: 48, - justifyContent: "center", - alignItems: "center", - borderWidth: 3, - borderColor: "rgba(255, 255, 255, 0.3)", + width: 100, + height: 100, + borderRadius: 50, + borderWidth: 4, + borderColor: 'rgba(255, 255, 255, 0.3)', }, - avatarText: { - fontSize: theme.typography.fontSize['4xl'], - fontWeight: theme.typography.fontWeight.bold, - color: theme.colors.white, + placeholderAvatar: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 4, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + editBadge: { + position: 'absolute', + bottom: 0, + right: 0, + backgroundColor: theme.colors.white, + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, name: { fontSize: theme.typography.fontSize['2xl'], fontWeight: theme.typography.fontWeight.bold, color: theme.colors.white, - marginBottom: 6, - }, - email: { - fontSize: theme.typography.fontSize.base, - color: "rgba(255, 255, 255, 0.9)", marginBottom: 4, }, - phone: { + email: { fontSize: theme.typography.fontSize.sm, - color: "rgba(255, 255, 255, 0.8)", + color: 'rgba(255, 255, 255, 0.8)', + marginBottom: 12, + }, + memberBadge: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + memberText: { + color: theme.colors.white, + fontSize: theme.typography.fontSize.xs, + fontWeight: theme.typography.fontWeight.bold, + letterSpacing: 0.5, + }, + content: { + padding: 20, + marginTop: -20, }, section: { marginBottom: 24, }, sectionTitle: { - fontSize: theme.typography.fontSize.xl, - fontWeight: theme.typography.fontWeight.semibold, + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.bold, color: theme.colors.gray900, marginBottom: 12, + marginLeft: 4, }, infoCard: { backgroundColor: theme.colors.white, borderRadius: theme.borderRadius.xl, - padding: 16, + padding: 8, + borderWidth: 1, + borderColor: theme.colors.gray100, }, infoRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 14, - borderBottomWidth: 1, - borderBottomColor: theme.colors.gray200, + flexDirection: 'row', + alignItems: 'center', + padding: 12, }, - infoLabel: { - flexDirection: "row", - alignItems: "center", - gap: 10, - }, - infoIconContainer: { + iconContainer: { width: 36, height: 36, - borderRadius: 18, - justifyContent: "center", - alignItems: "center", - }, - infoLabelText: { - fontSize: theme.typography.fontSize.sm, - color: theme.colors.gray700, - fontWeight: theme.typography.fontWeight.medium, - }, - infoValue: { - fontSize: theme.typography.fontSize.sm, - color: theme.colors.gray900, - fontWeight: theme.typography.fontWeight.semibold, - }, - actionButton: { - flexDirection: "row", - alignItems: "center", - backgroundColor: theme.colors.white, - borderRadius: theme.borderRadius.xl, - padding: 16, - marginBottom: 12, - }, - actionIconContainer: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: "center", - alignItems: "center", + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', marginRight: 12, }, - actionButtonText: { + infoLabel: { flex: 1, fontSize: theme.typography.fontSize.base, color: theme.colors.gray900, fontWeight: theme.typography.fontWeight.medium, }, - logoutButton: { - paddingVertical: 16, - borderRadius: theme.borderRadius.lg, - alignItems: "center", - justifyContent: "center", - flexDirection: "row", - gap: 8, - marginTop: 8, - marginBottom: 16, + divider: { + height: 1, + backgroundColor: theme.colors.gray100, + marginLeft: 60, }, - logoutText: { - color: theme.colors.white, - fontSize: theme.typography.fontSize.base, - fontWeight: theme.typography.fontWeight.semibold, + signOutButton: { + marginTop: 8, }, version: { - textAlign: "center", + textAlign: 'center', + marginTop: 24, + color: theme.colors.gray400, fontSize: theme.typography.fontSize.xs, - color: theme.colors.gray500, - marginTop: 8, - marginBottom: 20, }, }); diff --git a/apps/mobile/src/components/ActivityWidget.tsx b/apps/mobile/src/components/ActivityWidget.tsx new file mode 100644 index 0000000..3c1e387 --- /dev/null +++ b/apps/mobile/src/components/ActivityWidget.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { View, Text, StyleSheet, Dimensions } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import { theme } from '../styles/theme'; + +const { width } = Dimensions.get('window'); + +interface ActivityWidgetProps { + steps: number; + calories: number; + duration: number; // in minutes +} + +export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) { + return ( + + + + Daily Activity + + + + + + + + + {steps.toLocaleString()} + Steps + + + + + + + + + {calories} + Kcal + + + + + + + + + {duration}m + Active + + + + {/* Simple Bar Chart Visualization */} + + {[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => ( + + + + {['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]} + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 20, + marginBottom: 20, + }, + card: { + borderRadius: 24, + padding: 20, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#fff', + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + }, + statItem: { + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, + }, + statValue: { + fontSize: 20, + fontWeight: '700', + color: '#fff', + marginBottom: 2, + }, + statLabel: { + fontSize: 12, + color: theme.colors.gray400, + fontWeight: '500', + }, + divider: { + width: 1, + height: 40, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + chartContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + height: 80, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 255, 255, 0.1)', + }, + barContainer: { + alignItems: 'center', + gap: 8, + }, + bar: { + width: 6, + borderRadius: 3, + opacity: 0.8, + }, + dayLabel: { + fontSize: 10, + color: theme.colors.gray500, + fontWeight: '600', + }, +}); diff --git a/apps/mobile/src/components/AnimatedButton.tsx b/apps/mobile/src/components/AnimatedButton.tsx index 2e6687f..82d0955 100644 --- a/apps/mobile/src/components/AnimatedButton.tsx +++ b/apps/mobile/src/components/AnimatedButton.tsx @@ -15,11 +15,13 @@ import { } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { theme } from '../styles/theme'; +import { haptics } from '../utils/haptics'; interface AnimatedButtonProps { - onPress: () => void; title: string; - variant?: 'primary' | 'secondary' | 'danger' | 'success'; + onPress: () => void; + variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning'; + size?: 'sm' | 'md' | 'lg'; loading?: boolean; disabled?: boolean; style?: ViewStyle; @@ -28,109 +30,125 @@ interface AnimatedButtonProps { } export function AnimatedButton({ - onPress, title, + onPress, variant = 'primary', + size = 'md', loading = false, disabled = false, style, textStyle, icon, }: AnimatedButtonProps) { - const scaleAnim = useRef(new Animated.Value(1)).current; + const scale = useRef(new Animated.Value(1)).current; const handlePressIn = () => { - Animated.spring(scaleAnim, { + Animated.spring(scale, { toValue: 0.95, - friction: 8, - tension: 100, useNativeDriver: true, }).start(); }; const handlePressOut = () => { - Animated.spring(scaleAnim, { + Animated.spring(scale, { toValue: 1, - friction: 8, - tension: 100, useNativeDriver: true, }).start(); }; - const getGradientColors = (): readonly [string, string, ...string[]] => { + const handlePress = () => { + if (!disabled && !loading) { + haptics.light(); + onPress(); + } + }; + + const getGradientColors = () => { + if (disabled) return ['#9ca3af', '#6b7280'] as const; switch (variant) { - case 'primary': - return theme.gradients.primary; - case 'danger': - return theme.gradients.danger; + case 'secondary': + return theme.gradients.purple; case 'success': return theme.gradients.success; - case 'secondary': - return [theme.colors.gray600, theme.colors.gray700] as const; + case 'danger': + return theme.gradients.danger; + case 'warning': + return theme.gradients.warning; default: return theme.gradients.primary; } }; - const getShadowStyle = () => { - if (disabled) return {}; - switch (variant) { - case 'danger': - return theme.shadows.glowDanger; + const getSizeStyles = () => { + switch (size) { + case 'sm': + return { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 }; + case 'lg': + return { paddingVertical: 16, paddingHorizontal: 32, fontSize: 18 }; default: - return theme.shadows.glow; + return { paddingVertical: 12, paddingHorizontal: 24, fontSize: 16 }; } }; + const sizeStyles = getSizeStyles(); + return ( - - + + {loading ? ( ) : ( <> - {icon} - {title} + {icon && {icon}} + + {title} + )} - - + + ); } const styles = StyleSheet.create({ - button: { - paddingVertical: 16, - paddingHorizontal: 24, - borderRadius: theme.borderRadius.lg, + container: { + alignItems: 'center', + justifyContent: 'center', + }, + gradient: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 8, + borderRadius: 12, + ...theme.shadows.medium, }, text: { - color: theme.colors.white, - fontSize: theme.typography.fontSize.base, - fontWeight: theme.typography.fontWeight.semibold, - }, - disabled: { - opacity: 0.5, + color: '#fff', + fontWeight: '600', + textAlign: 'center', }, }); diff --git a/apps/mobile/src/components/CircularProgress.tsx b/apps/mobile/src/components/CircularProgress.tsx new file mode 100644 index 0000000..61bfc62 --- /dev/null +++ b/apps/mobile/src/components/CircularProgress.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Animated, StyleSheet, Text } from 'react-native'; +import Svg, { Circle } from 'react-native-svg'; +import { theme } from '../styles/theme'; + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +interface CircularProgressProps { + size?: number; + strokeWidth?: number; + progress: number; // 0 to 100 + color?: string; + backgroundColor?: string; +} + +export function CircularProgress({ + size = 60, + strokeWidth = 6, + progress, + color = theme.colors.primary, + backgroundColor = theme.colors.gray200, +}: CircularProgressProps) { + const animatedValue = useRef(new Animated.Value(0)).current; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + + useEffect(() => { + Animated.timing(animatedValue, { + toValue: progress, + duration: 1000, + useNativeDriver: true, + }).start(); + }, [progress]); + + const strokeDashoffset = animatedValue.interpolate({ + inputRange: [0, 100], + outputRange: [circumference, 0], + }); + + return ( + + + + + + + + {Math.round(progress)}% + + + + ); +} diff --git a/apps/mobile/src/components/CustomTabBar.tsx b/apps/mobile/src/components/CustomTabBar.tsx new file mode 100644 index 0000000..cad05e1 --- /dev/null +++ b/apps/mobile/src/components/CustomTabBar.tsx @@ -0,0 +1,143 @@ +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'; + +const { width } = Dimensions.get('window'); + +export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) { + return ( + + + {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, + }); + + 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'; + } + }; + + // 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 ( + + + {isFocused ? ( + + + + ) : ( + + )} + + + ); + })} + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/components/GoalCarousel.tsx b/apps/mobile/src/components/GoalCarousel.tsx new file mode 100644 index 0000000..323f0a5 --- /dev/null +++ b/apps/mobile/src/components/GoalCarousel.tsx @@ -0,0 +1,223 @@ +import React, { useRef } from 'react'; +import { View, Text, StyleSheet, Dimensions, ScrollView, TouchableOpacity, Animated } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import { theme } from '../styles/theme'; +import { CircularProgress } from './CircularProgress'; +import type { FitnessGoal } from '../services/fitnessGoals'; + +const { width } = Dimensions.get('window'); +const CARD_WIDTH = width * 0.8; +const SPACING = 20; + +interface GoalCarouselProps { + goals: FitnessGoal[]; + onGoalPress: (goal: FitnessGoal) => void; +} + +export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) { + const scrollX = useRef(new Animated.Value(0)).current; + + if (goals.length === 0) { + return ( + + No active goals + + ); + } + + return ( + + + {goals.map((goal, index) => { + const inputRange = [ + (index - 1) * (CARD_WIDTH + SPACING), + index * (CARD_WIDTH + SPACING), + (index + 1) * (CARD_WIDTH + SPACING), + ]; + + const scale = scrollX.interpolate({ + inputRange, + outputRange: [0.9, 1, 0.9], + extrapolate: 'clamp', + }); + + const opacity = scrollX.interpolate({ + inputRange, + outputRange: [0.7, 1, 0.7], + extrapolate: 'clamp', + }); + + return ( + onGoalPress(goal)} + > + + + + + + + + + + {goal.priority.toUpperCase()} + + + + {goal.title} + {goal.description} + + + + + + {goal.currentValue} / {goal.targetValue} + + {goal.unit} + + + + + + Target: {goal.targetDate ? new Date(goal.targetDate).toLocaleDateString() : 'No date'} + + + + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + scrollContent: { + paddingHorizontal: (width - CARD_WIDTH) / 2, + paddingVertical: 20, + }, + emptyContainer: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: theme.colors.gray500, + fontSize: 16, + }, + cardContainer: { + width: CARD_WIDTH, + marginRight: SPACING, + }, + card: { + borderRadius: 24, + padding: 24, + height: 320, + justifyContent: 'space-between', + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.05)', + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, + }, + iconContainer: { + shadowColor: theme.colors.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, + iconGradient: { + width: 48, + height: 48, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + priorityBadge: { + backgroundColor: 'rgba(59, 130, 246, 0.1)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + priorityText: { + color: theme.colors.primary, + fontSize: 12, + fontWeight: '700', + }, + title: { + fontSize: 24, + fontWeight: '800', + color: theme.colors.gray900, + marginBottom: 8, + }, + description: { + fontSize: 14, + color: theme.colors.gray500, + marginBottom: 24, + lineHeight: 20, + }, + progressContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 24, + }, + progressDetails: { + alignItems: 'flex-end', + }, + progressValue: { + fontSize: 24, + fontWeight: '700', + color: theme.colors.gray900, + }, + progressUnit: { + fontSize: 14, + color: theme.colors.gray500, + fontWeight: '500', + }, + footer: { + borderTopWidth: 1, + borderTopColor: theme.colors.gray100, + paddingTop: 16, + }, + date: { + fontSize: 12, + color: theme.colors.gray400, + fontWeight: '500', + }, +}); diff --git a/apps/mobile/src/components/GoalCreationModal.tsx b/apps/mobile/src/components/GoalCreationModal.tsx index d078578..8086ef9 100644 --- a/apps/mobile/src/components/GoalCreationModal.tsx +++ b/apps/mobile/src/components/GoalCreationModal.tsx @@ -8,6 +8,7 @@ import { TouchableOpacity, ScrollView, Platform, + Alert, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; @@ -59,7 +60,7 @@ export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationMo const handleSubmit = async () => { if (!title.trim()) { - alert('Please enter a goal title'); + Alert.alert('Error', 'Please enter a goal title'); return; } @@ -81,7 +82,7 @@ export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationMo onClose(); } catch (error) { console.error('Error creating goal:', error); - alert('Failed to create goal. Please try again.'); + Alert.alert('Error', 'Failed to create goal. Please try again.'); } finally { setSubmitting(false); } @@ -203,28 +204,31 @@ export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationMo Target Date setShowDatePicker(true)} + onPress={() => setShowDatePicker(!showDatePicker)} > {targetDate ? targetDate.toLocaleDateString() : 'Select target date'} - + {showDatePicker && ( - { - setShowDatePicker(Platform.OS === 'ios'); - if (selectedDate) { - setTargetDate(selectedDate); - } - }} - minimumDate={new Date()} - /> + + { + setShowDatePicker(Platform.OS === 'ios'); + if (selectedDate) { + setTargetDate(selectedDate); + } + }} + minimumDate={new Date()} + themeVariant="light" + /> + )} {/* Priority */} @@ -411,4 +415,12 @@ const styles = StyleSheet.create({ fontWeight: '600', color: '#fff', }, + datePickerContainer: { + backgroundColor: '#fff', + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + marginTop: 8, + overflow: 'hidden', + }, }); diff --git a/apps/mobile/src/components/ParallaxScrollView.tsx b/apps/mobile/src/components/ParallaxScrollView.tsx new file mode 100644 index 0000000..221dd5a --- /dev/null +++ b/apps/mobile/src/components/ParallaxScrollView.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { StyleSheet, View, Animated, Dimensions, ImageSourcePropType } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { theme } from '../styles/theme'; + +const { width } = Dimensions.get('window'); +const HEADER_HEIGHT = 300; + +interface ParallaxScrollViewProps { + children: React.ReactNode; + headerImage?: ImageSourcePropType; + headerBackgroundColor?: string; + headerContent?: React.ReactNode; +} + +export function ParallaxScrollView({ + children, + headerImage, + headerBackgroundColor = theme.colors.primary, + headerContent, +}: ParallaxScrollViewProps) { + const scrollY = React.useRef(new Animated.Value(0)).current; + + const headerTranslateY = scrollY.interpolate({ + inputRange: [0, HEADER_HEIGHT], + outputRange: [0, -HEADER_HEIGHT / 2], + extrapolate: 'clamp', + }); + + const imageScale = scrollY.interpolate({ + inputRange: [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + outputRange: [2, 1, 1], + extrapolate: 'clamp', + }); + + const headerOpacity = scrollY.interpolate({ + inputRange: [0, HEADER_HEIGHT / 2], + outputRange: [1, 0], + extrapolate: 'clamp', + }); + + return ( + + + {children} + + + + + + {headerImage && ( + + )} + + + + {headerContent} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + scrollContent: { + paddingTop: HEADER_HEIGHT, + }, + contentContainer: { + backgroundColor: theme.colors.background, + borderTopLeftRadius: 32, + borderTopRightRadius: 32, + marginTop: -32, + paddingTop: 32, + minHeight: 800, + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: HEADER_HEIGHT, + overflow: 'hidden', + zIndex: 1, + }, + headerImage: { + ...StyleSheet.absoluteFillObject, + width: undefined, + height: undefined, + resizeMode: 'cover', + }, + headerContent: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 32, + }, +}); diff --git a/apps/mobile/src/components/QuickActionGrid.tsx b/apps/mobile/src/components/QuickActionGrid.tsx new file mode 100644 index 0000000..b8c1c58 --- /dev/null +++ b/apps/mobile/src/components/QuickActionGrid.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import { theme } from '../styles/theme'; + +const { width } = Dimensions.get('window'); +const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2 + +interface QuickActionProps { + icon: keyof typeof Ionicons.glyphMap; + label: string; + gradient: readonly [string, string, ...string[]]; + onPress?: () => void; +} + +function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) { + return ( + + + + + + {label} + + + + ); +} + +export function QuickActionGrid() { + return ( + + Quick Actions + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + marginBottom: 24, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: theme.colors.gray900, + marginBottom: 16, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, + }, + itemContainer: { + width: ITEM_WIDTH, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 20, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.6)', + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.gray800, + flex: 1, + }, + arrow: { + opacity: 0.5, + }, +}); diff --git a/apps/mobile/src/services/fitnessGoals.ts b/apps/mobile/src/services/fitnessGoals.ts index 078d046..dc16dfc 100644 --- a/apps/mobile/src/services/fitnessGoals.ts +++ b/apps/mobile/src/services/fitnessGoals.ts @@ -34,8 +34,8 @@ export interface CreateGoalData { } export class FitnessGoalsService { - private async getAuthHeaders(token: string | null): Promise { - const headers: HeadersInit = { + private async getAuthHeaders(token: string | null): Promise { + const headers: any = { 'Content-Type': 'application/json', }; diff --git a/apps/mobile/src/styles/theme.ts b/apps/mobile/src/styles/theme.ts index da73eee..4a10c78 100644 --- a/apps/mobile/src/styles/theme.ts +++ b/apps/mobile/src/styles/theme.ts @@ -10,6 +10,7 @@ export const theme = { primary: '#3b82f6', primaryDark: '#2563eb', primaryLight: '#60a5fa', + secondary: '#8b5cf6', // Accent colors purple: '#8b5cf6', diff --git a/apps/mobile/src/utils/haptics.ts b/apps/mobile/src/utils/haptics.ts new file mode 100644 index 0000000..1cdd672 --- /dev/null +++ b/apps/mobile/src/utils/haptics.ts @@ -0,0 +1,40 @@ +import * as Haptics from 'expo-haptics'; +import { Platform } from 'react-native'; + +export const haptics = { + light: () => { + if (Platform.OS !== 'web') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + }, + medium: () => { + if (Platform.OS !== 'web') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + }, + heavy: () => { + if (Platform.OS !== 'web') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + } + }, + success: () => { + if (Platform.OS !== 'web') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + }, + warning: () => { + if (Platform.OS !== 'web') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } + }, + error: () => { + if (Platform.OS !== 'web') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + }, + selection: () => { + if (Platform.OS !== 'web') { + Haptics.selectionAsync(); + } + }, +};