mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-04 00:02:06 +00:00
nip update and WIP social tab
This commit is contained in:
parent
15c973f333
commit
dca4ef5b33
@ -38,7 +38,7 @@ export default function TabLayout() {
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.tabActive,
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: {
|
||||
|
@ -1,32 +0,0 @@
|
||||
// app/(tabs)/social.tsx
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bell } from 'lucide-react-native';
|
||||
import Header from '@/components/Header';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
|
||||
export default function SocialScreen() {
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
title="Social"
|
||||
rightElement={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => console.log('Open notifications')}
|
||||
>
|
||||
<View className="relative">
|
||||
<Bell className="text-foreground" />
|
||||
<View className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
</View>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Social Screen</Text>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
63
app/(tabs)/social/_layout.tsx
Normal file
63
app/(tabs)/social/_layout.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
// app/(tabs)/social/_layout.tsx
|
||||
import React from 'react';
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import FollowingScreen from './following';
|
||||
import PowerScreen from './powr';
|
||||
import GlobalScreen from './global';
|
||||
import Header from '@/components/Header';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
|
||||
const Tab = createMaterialTopTabNavigator();
|
||||
|
||||
export default function SocialLayout() {
|
||||
const theme = useTheme() as CustomTheme;
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header useLogo={true} />
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="following"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: theme.colors.tabIndicator,
|
||||
height: 2,
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tabBarPressColor: theme.colors.primary,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="following"
|
||||
component={FollowingScreen}
|
||||
options={{ title: 'Following' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="powr"
|
||||
component={PowerScreen}
|
||||
options={{ title: 'POWR' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="global"
|
||||
component={GlobalScreen}
|
||||
options={{ title: 'Global' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
90
app/(tabs)/social/following.tsx
Normal file
90
app/(tabs)/social/following.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
// app/(tabs)/social/following.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SocialPost from '@/components/social/SocialPost';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import NostrLoginPrompt from '@/components/social/NostrLoginPrompt';
|
||||
import EmptyFeed from '@/components/social/EmptyFeed';
|
||||
|
||||
// Sample mock data for posts
|
||||
const MOCK_POSTS = [
|
||||
{
|
||||
id: '1',
|
||||
author: {
|
||||
name: 'Jane Fitness',
|
||||
handle: 'janefitness',
|
||||
avatar: 'https://randomuser.me/api/portraits/women/32.jpg',
|
||||
pubkey: 'npub1q8s7vw...'
|
||||
},
|
||||
content: 'Just crushed this leg workout! New PR on squat 💪 #fitness #legday',
|
||||
createdAt: new Date(Date.now() - 3600000 * 2), // 2 hours ago
|
||||
metrics: {
|
||||
likes: 24,
|
||||
comments: 5,
|
||||
reposts: 3
|
||||
},
|
||||
workout: {
|
||||
title: 'Leg Day Destroyer',
|
||||
exercises: ['Squats', 'Lunges', 'Leg Press'],
|
||||
duration: 45
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: {
|
||||
name: 'Mark Strong',
|
||||
handle: 'markstrong',
|
||||
avatar: 'https://randomuser.me/api/portraits/men/45.jpg',
|
||||
pubkey: 'npub1z92r3...'
|
||||
},
|
||||
content: 'Morning cardio session complete! 5K run in 22 minutes. Starting the day right! #running #cardio',
|
||||
createdAt: new Date(Date.now() - 3600000 * 5), // 5 hours ago
|
||||
metrics: {
|
||||
likes: 18,
|
||||
comments: 2,
|
||||
reposts: 1
|
||||
},
|
||||
workout: {
|
||||
title: 'Morning Cardio',
|
||||
exercises: ['Running'],
|
||||
duration: 22
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function FollowingScreen() {
|
||||
const { isAuthenticated } = useNDKCurrentUser();
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [posts, setPosts] = React.useState(MOCK_POSTS);
|
||||
|
||||
const onRefresh = React.useCallback(() => {
|
||||
setRefreshing(true);
|
||||
// Simulate fetch - in a real app, this would be a call to load posts
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <NostrLoginPrompt message="Log in to see posts from people you follow" />;
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return <EmptyFeed message="You're not following anyone yet. Discover people to follow in the POWR or Global feeds." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{posts.map(post => (
|
||||
<SocialPost key={post.id} post={post} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
114
app/(tabs)/social/global.tsx
Normal file
114
app/(tabs)/social/global.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
// app/(tabs)/social/global.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import SocialPost from '@/components/social/SocialPost';
|
||||
|
||||
// Sample mock data for global feed - more diverse content
|
||||
const GLOBAL_POSTS = [
|
||||
{
|
||||
id: '1',
|
||||
author: {
|
||||
name: 'Strength Coach',
|
||||
handle: 'strengthcoach',
|
||||
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
|
||||
pubkey: 'npub1q8s7vw...'
|
||||
},
|
||||
content: 'Form tip: When squatting, make sure your knees track in line with your toes. This helps protect your knees and ensures proper muscle engagement. #squatform #technique',
|
||||
createdAt: new Date(Date.now() - 3600000 * 3), // 3 hours ago
|
||||
metrics: {
|
||||
likes: 132,
|
||||
comments: 28,
|
||||
reposts: 45
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: {
|
||||
name: 'Marathon Runner',
|
||||
handle: 'marathoner',
|
||||
avatar: 'https://randomuser.me/api/portraits/women/28.jpg',
|
||||
pubkey: 'npub1z92r3...'
|
||||
},
|
||||
content: 'Just finished my 10th marathon this year! Boston Marathon was an amazing experience. Thanks for all the support! #marathon #running #endurance',
|
||||
createdAt: new Date(Date.now() - 3600000 * 14), // 14 hours ago
|
||||
metrics: {
|
||||
likes: 214,
|
||||
comments: 38,
|
||||
reposts: 22
|
||||
},
|
||||
workout: {
|
||||
title: 'Boston Marathon',
|
||||
exercises: ['Running'],
|
||||
duration: 218 // 3:38 marathon
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
author: {
|
||||
name: 'PowerLifter',
|
||||
handle: 'liftsheavy',
|
||||
avatar: 'https://randomuser.me/api/portraits/men/85.jpg',
|
||||
pubkey: 'npub1xne8q...'
|
||||
},
|
||||
content: 'NEW PR ALERT! 💪 Just hit 500lbs on deadlift after 3 years of consistent training. Proof that patience and consistency always win. #powerlifting #deadlift #pr',
|
||||
createdAt: new Date(Date.now() - 3600000 * 36), // 36 hours ago
|
||||
metrics: {
|
||||
likes: 347,
|
||||
comments: 72,
|
||||
reposts: 41
|
||||
},
|
||||
workout: {
|
||||
title: 'Deadlift Day',
|
||||
exercises: ['Deadlifts', 'Back Accessories'],
|
||||
duration: 65
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
author: {
|
||||
name: 'Yoga Master',
|
||||
handle: 'yogalife',
|
||||
avatar: 'https://randomuser.me/api/portraits/women/50.jpg',
|
||||
pubkey: 'npub1r72df...'
|
||||
},
|
||||
content: 'Morning yoga flow to start the day centered and grounded. Remember that flexibility isn\'t just physical - it\'s mental too. #yoga #morningroutine #wellness',
|
||||
createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
|
||||
metrics: {
|
||||
likes: 183,
|
||||
comments: 12,
|
||||
reposts: 25
|
||||
},
|
||||
workout: {
|
||||
title: 'Morning Yoga Flow',
|
||||
exercises: ['Yoga'],
|
||||
duration: 30
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function GlobalScreen() {
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [posts, setPosts] = React.useState(GLOBAL_POSTS);
|
||||
|
||||
const onRefresh = React.useCallback(() => {
|
||||
setRefreshing(true);
|
||||
// Simulate fetch
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{posts.map(post => (
|
||||
<SocialPost key={post.id} post={post} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
105
app/(tabs)/social/powr.tsx
Normal file
105
app/(tabs)/social/powr.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
// app/(tabs)/social/powr.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import SocialPost from '@/components/social/SocialPost';
|
||||
import { Zap } from 'lucide-react-native';
|
||||
|
||||
// Sample mock data for posts from POWR team/recommendations
|
||||
const POWR_POSTS = [
|
||||
{
|
||||
id: '1',
|
||||
author: {
|
||||
name: 'POWR Team',
|
||||
handle: 'powrteam',
|
||||
avatar: 'https://i.pravatar.cc/150?img=12',
|
||||
pubkey: 'npub1q8s7vw...',
|
||||
verified: true
|
||||
},
|
||||
content: 'Welcome to the new social feed in POWR! Share your workouts, follow friends and get inspired by the global fitness community. #powrapp',
|
||||
createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
|
||||
metrics: {
|
||||
likes: 158,
|
||||
comments: 42,
|
||||
reposts: 27
|
||||
},
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: {
|
||||
name: 'Sarah Trainer',
|
||||
handle: 'sarahfitness',
|
||||
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
|
||||
pubkey: 'npub1z92r3...'
|
||||
},
|
||||
content: 'Just released my new 30-day strength program! Check it out in my profile and let me know what you think. #strengthtraining #30daychallenge',
|
||||
createdAt: new Date(Date.now() - 3600000 * 24), // 1 day ago
|
||||
metrics: {
|
||||
likes: 84,
|
||||
comments: 15,
|
||||
reposts: 12
|
||||
},
|
||||
workout: {
|
||||
title: '30-Day Strength Builder',
|
||||
exercises: ['Full Program'],
|
||||
isProgramPreview: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
author: {
|
||||
name: 'POWR Team',
|
||||
handle: 'powrteam',
|
||||
avatar: 'https://i.pravatar.cc/150?img=12',
|
||||
pubkey: 'npub1q8s7vw...',
|
||||
verified: true
|
||||
},
|
||||
content: 'New features alert! You can now track your rest periods automatically and share your PRs directly to your feed. Update to the latest version to try it out!',
|
||||
createdAt: new Date(Date.now() - 3600000 * 72), // 3 days ago
|
||||
metrics: {
|
||||
likes: 207,
|
||||
comments: 31,
|
||||
reposts: 45
|
||||
},
|
||||
featured: true
|
||||
}
|
||||
];
|
||||
|
||||
export default function PowerScreen() {
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [posts, setPosts] = React.useState(POWR_POSTS);
|
||||
|
||||
const onRefresh = React.useCallback(() => {
|
||||
setRefreshing(true);
|
||||
// Simulate fetch
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* POWR Welcome Section */}
|
||||
<View className="p-4 border-b border-border bg-primary/5">
|
||||
<View className="flex-row items-center mb-2">
|
||||
<Zap size={20} className="mr-2 text-primary" fill="currentColor" />
|
||||
<Text className="text-lg font-bold">POWR Community</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground">
|
||||
Official updates, featured content, and community highlights from the POWR team.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Posts */}
|
||||
{posts.map(post => (
|
||||
<SocialPost key={post.id} post={post} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
// app/(workout)/create.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, StyleSheet, TextInput } from 'react-native';
|
||||
import { View, ScrollView, StyleSheet } from 'react-native';
|
||||
import { router, useNavigation } from 'expo-router';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import { Text } from '@/components/ui/text';
|
||||
@ -16,7 +16,7 @@ import {
|
||||
AlertDialogCancel
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { Plus, Pause, Play, MoreHorizontal, CheckCircle2, Dumbbell, ChevronLeft } from 'lucide-react-native';
|
||||
import { Plus, Pause, Play, MoreHorizontal, Dumbbell, ChevronLeft } from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import EditableText from '@/components/EditableText';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -25,6 +25,7 @@ import { WorkoutSet } from '@/types/workout';
|
||||
import { formatTime } from '@/utils/formatTime';
|
||||
import { ParamListBase } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import SetInput from '@/components/workout/SetInput';
|
||||
|
||||
// Define styles outside of component
|
||||
const styles = StyleSheet.create({
|
||||
@ -116,19 +117,6 @@ export default function CreateWorkoutScreen() {
|
||||
updateSet(exerciseIndex, exercise.sets.length, newSet);
|
||||
};
|
||||
|
||||
// Handler for completing a set
|
||||
const handleCompleteSet = (exerciseIndex: number, setIndex: number) => {
|
||||
if (!activeWorkout) return;
|
||||
|
||||
const exercise = activeWorkout.exercises[exerciseIndex];
|
||||
const set = exercise.sets[setIndex];
|
||||
|
||||
updateSet(exerciseIndex, setIndex, {
|
||||
...set,
|
||||
isCompleted: !set.isCompleted
|
||||
});
|
||||
};
|
||||
|
||||
// Show empty state when no workout is active
|
||||
if (!activeWorkout) {
|
||||
return (
|
||||
@ -280,7 +268,7 @@ export default function CreateWorkoutScreen() {
|
||||
>
|
||||
{/* Exercise Header */}
|
||||
<View className="flex-row justify-between items-center px-4 py-3 border-b border-border">
|
||||
<Text className="text-lg font-semibold text-purple">
|
||||
<Text className="text-lg font-semibold text-[#8B5CF6]">
|
||||
{exercise.title}
|
||||
</Text>
|
||||
<Button
|
||||
@ -314,77 +302,19 @@ export default function CreateWorkoutScreen() {
|
||||
{/* Exercise Sets */}
|
||||
<CardContent className="p-0">
|
||||
{exercise.sets.map((set, setIndex) => {
|
||||
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : null;
|
||||
const previousSet = setIndex > 0 ? exercise.sets[setIndex - 1] : undefined;
|
||||
|
||||
return (
|
||||
<View
|
||||
<SetInput
|
||||
key={set.id}
|
||||
className={cn(
|
||||
"flex-row items-center px-4 py-3 border-t border-border",
|
||||
set.isCompleted && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* Set Number */}
|
||||
<Text className="w-16 text-base font-medium text-foreground">
|
||||
{setIndex + 1}
|
||||
</Text>
|
||||
|
||||
{/* Previous Set */}
|
||||
<Text className="w-20 text-sm text-muted-foreground">
|
||||
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
|
||||
</Text>
|
||||
|
||||
{/* Weight Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.weight ? set.weight.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const weight = text === '' ? 0 : parseFloat(text);
|
||||
if (!isNaN(weight)) {
|
||||
updateSet(exerciseIndex, setIndex, { weight });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Reps Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.reps ? set.reps.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const reps = text === '' ? 0 : parseInt(text, 10);
|
||||
if (!isNaN(reps)) {
|
||||
updateSet(exerciseIndex, setIndex, { reps });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Complete Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-11 h-11"
|
||||
onPress={() => handleCompleteSet(exerciseIndex, setIndex)}
|
||||
>
|
||||
<CheckCircle2
|
||||
className={set.isCompleted ? "text-purple" : "text-muted-foreground"}
|
||||
fill={set.isCompleted ? "currentColor" : "none"}
|
||||
size={22}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
exerciseIndex={exerciseIndex}
|
||||
setIndex={setIndex}
|
||||
setNumber={setIndex + 1}
|
||||
weight={set.weight}
|
||||
reps={set.reps}
|
||||
isCompleted={set.isCompleted}
|
||||
previousSet={previousSet}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
|
@ -32,7 +32,7 @@ const UserAvatar = ({
|
||||
}, [uri]);
|
||||
|
||||
// Log the URI for debugging
|
||||
console.log("Avatar URI:", uri);
|
||||
// console.log("Avatar URI:", uri);
|
||||
|
||||
const containerStyles = cn(
|
||||
{
|
||||
|
25
components/social/EmptyFeed.tsx
Normal file
25
components/social/EmptyFeed.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
// components/social/EmptyFeed.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Users } from 'lucide-react-native';
|
||||
|
||||
interface EmptyFeedProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function EmptyFeed({ message }: EmptyFeedProps) {
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-background">
|
||||
<View className="flex-1 items-center justify-center p-10 mt-10">
|
||||
<Users size={48} className="text-muted-foreground mb-4" />
|
||||
<Text className="text-lg font-semibold mb-2 text-center">
|
||||
No posts yet
|
||||
</Text>
|
||||
<Text className="text-center text-muted-foreground">
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
40
components/social/NostrLoginPrompt.tsx
Normal file
40
components/social/NostrLoginPrompt.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
// components/social/NostrLoginPrompt.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Zap, Key } from 'lucide-react-native';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
|
||||
interface NostrLoginPromptProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function NostrLoginPrompt({ message }: NostrLoginPromptProps) {
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<View className="items-center mb-8">
|
||||
<Zap size={48} className="text-primary mb-4" />
|
||||
<Text className="text-xl font-semibold mb-4 text-center">Connect with Nostr</Text>
|
||||
<Text className="text-center text-muted-foreground mb-8">
|
||||
{message}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => setIsLoginSheetOpen(true)}
|
||||
className="px-6"
|
||||
>
|
||||
<Key size={18} className="mr-2" />
|
||||
<Text>Login with Nostr</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
237
components/social/SocialPost.tsx
Normal file
237
components/social/SocialPost.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
// components/social/SocialPost.tsx
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Heart, MessageSquare, Repeat, Share2, Zap, Clock, Dumbbell, CheckCircle } from 'lucide-react-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Type definition for a social post
|
||||
interface PostAuthor {
|
||||
name: string;
|
||||
handle: string;
|
||||
avatar: string;
|
||||
pubkey: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
interface WorkoutInfo {
|
||||
title: string;
|
||||
exercises: string[];
|
||||
duration?: number;
|
||||
isProgramPreview?: boolean;
|
||||
}
|
||||
|
||||
interface PostMetrics {
|
||||
likes: number;
|
||||
comments: number;
|
||||
reposts: number;
|
||||
zaps?: number;
|
||||
}
|
||||
|
||||
interface SocialPostProps {
|
||||
post: {
|
||||
id: string;
|
||||
author: PostAuthor;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
metrics: PostMetrics;
|
||||
workout?: WorkoutInfo;
|
||||
featured?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SocialPost({ post }: SocialPostProps) {
|
||||
const [liked, setLiked] = React.useState(false);
|
||||
const [reposted, setReposted] = React.useState(false);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
return 'now';
|
||||
} else if (diffInHours < 24) {
|
||||
return `${diffInHours}h`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays}d`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={cn(
|
||||
"p-4 border-b border-border",
|
||||
post.featured && "bg-primary/5"
|
||||
)}>
|
||||
{/* Author info */}
|
||||
<View className="flex-row mb-3">
|
||||
<Avatar className="h-10 w-10 mr-3" alt={`${post.author.name}'s profile picture`}>
|
||||
<AvatarImage source={{ uri: post.author.avatar }} />
|
||||
<AvatarFallback>
|
||||
<Text>{post.author.name.substring(0, 2)}</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Text className="font-semibold">{post.author.name}</Text>
|
||||
{post.author.verified && (
|
||||
<CheckCircle size={14} className="text-primary ml-1" fill="currentColor" />
|
||||
)}
|
||||
<Text className="text-muted-foreground ml-1 text-sm">
|
||||
@{post.author.handle} · {formatDate(post.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
{post.author.pubkey.substring(0, 10)}...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Post content */}
|
||||
<Text className="mb-3">{post.content}</Text>
|
||||
|
||||
{/* Workout card - slimmer version */}
|
||||
{post.workout && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
className="border border-border rounded-lg mb-3 bg-muted/20 overflow-hidden"
|
||||
>
|
||||
<View className="p-3">
|
||||
<View className="flex-row items-center mb-1">
|
||||
<Dumbbell size={14} className="text-primary mr-2" />
|
||||
<Text className="font-medium">{post.workout.title}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row flex-wrap flex-1">
|
||||
{post.workout.isProgramPreview ? (
|
||||
<Badge variant="outline" className="mr-2 mb-1">
|
||||
<Text className="text-xs">Program</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
post.workout.exercises.map((ex, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="mr-2 mb-1"
|
||||
>
|
||||
<Text className="text-xs">{ex}</Text>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{post.workout.duration && (
|
||||
<View className="flex-row items-center">
|
||||
<Clock size={14} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{formatDuration(post.workout.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<View className="flex-row justify-between items-center mt-2">
|
||||
{/* Comment button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<MessageSquare size={18} className="text-muted-foreground" />
|
||||
{post.metrics.comments > 0 && (
|
||||
<Text className="text-xs text-muted-foreground ml-1">
|
||||
{post.metrics.comments}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Repost button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
className="flex-row items-center"
|
||||
onPress={() => setReposted(!reposted)}
|
||||
>
|
||||
<Repeat
|
||||
size={18}
|
||||
className={reposted ? "text-green-500" : "text-muted-foreground"}
|
||||
/>
|
||||
{(reposted || post.metrics.reposts > 0) && (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs ml-1",
|
||||
reposted ? "text-green-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{reposted ? post.metrics.reposts + 1 : post.metrics.reposts}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Like button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
className="flex-row items-center"
|
||||
onPress={() => setLiked(!liked)}
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={cn(
|
||||
liked ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
fill={liked ? "#ef4444" : "none"}
|
||||
/>
|
||||
{(liked || post.metrics.likes > 0) && (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs ml-1",
|
||||
liked ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{liked ? post.metrics.likes + 1 : post.metrics.likes}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Zap button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<Zap
|
||||
size={18}
|
||||
className="text-amber-500"
|
||||
/>
|
||||
{post.metrics.zaps && post.metrics.zaps > 0 && (
|
||||
<Text className="text-xs text-muted-foreground ml-1">
|
||||
{post.metrics.zaps}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Share button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={18} className="text-muted-foreground" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
@ -2,10 +2,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { useColorScheme } from '@/lib/useColorScheme';
|
||||
import type { WorkoutSet } from '@/types/workout';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
@ -28,6 +28,9 @@ export default function SetInput({
|
||||
isCompleted = false,
|
||||
previousSet
|
||||
}: SetInputProps) {
|
||||
// Get theme colors
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
|
||||
// Local state for controlled inputs
|
||||
const [weightValue, setWeightValue] = useState(weight.toString());
|
||||
const [repsValue, setRepsValue] = useState(reps.toString());
|
||||
@ -72,7 +75,7 @@ export default function SetInput({
|
||||
|
||||
const handleCompleteSet = useCallback(() => {
|
||||
completeSet(exerciseIndex, setIndex);
|
||||
}, [exerciseIndex, setIndex]);
|
||||
}, [exerciseIndex, setIndex, completeSet]);
|
||||
|
||||
const handleCopyPreviousWeight = useCallback(() => {
|
||||
if (previousSet?.weight) {
|
||||
@ -86,6 +89,13 @@ export default function SetInput({
|
||||
}
|
||||
}, [previousSet]);
|
||||
|
||||
// Get the appropriate colors based on theme variables
|
||||
// Using the --purple and --muted-foreground from your theme
|
||||
const purpleColor = 'hsl(261, 90%, 66%)'; // --purple from your constants
|
||||
const mutedForegroundColor = isDarkColorScheme
|
||||
? 'hsl(240, 5%, 64.9%)' // --muted-foreground dark
|
||||
: 'hsl(240, 3.8%, 46.1%)'; // --muted-foreground light
|
||||
|
||||
return (
|
||||
<View className={cn(
|
||||
"flex-row items-center px-4 py-2 border-b border-border",
|
||||
@ -117,7 +127,7 @@ export default function SetInput({
|
||||
onChangeText={handleWeightChange}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor="text-muted-foreground"
|
||||
placeholderTextColor={mutedForegroundColor}
|
||||
returnKeyType="next"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
@ -139,26 +149,23 @@ export default function SetInput({
|
||||
onChangeText={handleRepsChange}
|
||||
keyboardType="number-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor="text-muted-foreground"
|
||||
placeholderTextColor={mutedForegroundColor}
|
||||
returnKeyType="done"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Complete Button */}
|
||||
<Button
|
||||
variant={isCompleted ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="w-10 h-10"
|
||||
{/* Complete Button using Feather icons with appropriate theme colors */}
|
||||
<TouchableOpacity
|
||||
className="w-10 h-10 items-center justify-center"
|
||||
onPress={handleCompleteSet}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isCompleted ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
<Feather
|
||||
name={isCompleted ? "check-circle" : "circle"}
|
||||
size={24}
|
||||
color={isCompleted ? purpleColor : mutedForegroundColor}
|
||||
/>
|
||||
</Button>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
@ -6,6 +6,22 @@ This specification defines workout events for fitness tracking. These workout ev
|
||||
|
||||
## Event Kinds
|
||||
|
||||
### Event Kind Selection Rationale
|
||||
|
||||
The event kinds in this NIP follow Nostr protocol conventions:
|
||||
|
||||
- **Exercise and Workout Templates** (33401, 33402) use parameterized replaceable event kinds (30000+) because:
|
||||
- They represent content that may be updated or improved over time
|
||||
- The author may want to replace previous versions with improved ones
|
||||
- They need the `d` parameter to distinguish between different templates by the same author
|
||||
- Multiple versions shouldn't accumulate in clients' storage
|
||||
|
||||
- **Workout Records** (1301) use a standard event kind (0-9999) because:
|
||||
- They represent a chronological feed of activity that shouldn't replace previous records
|
||||
- Each workout is a unique occurrence that adds to a user's history
|
||||
- Users publish multiple records over time, creating a timeline
|
||||
- They're conceptually similar to notes (kind 1) but with structured fitness data
|
||||
|
||||
### Exercise Template (kind: 33401)
|
||||
Defines reusable exercise definitions. These should remain public to enable discovery and sharing. The `content` field contains detailed form instructions and notes.
|
||||
|
||||
@ -37,7 +53,7 @@ Defines a complete workout plan. The `content` field contains workout notes and
|
||||
* `rest_between_rounds` - Rest time between rounds in seconds
|
||||
* `t` - Hashtags for categorization
|
||||
|
||||
### Workout Record (kind: 33403)
|
||||
### Workout Record (kind: 1301)
|
||||
Records a completed workout session. The `content` field contains notes about the workout.
|
||||
|
||||
#### Required Tags
|
||||
@ -52,6 +68,7 @@ Records a completed workout session. The `content` field contains notes about th
|
||||
#### Optional Tags
|
||||
* `rounds_completed` - Number of rounds completed
|
||||
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
|
||||
* `template` - Reference to the workout template used, if any. Format: ["template", "33402:<pubkey>:<d-tag>", "<relay-url>"]
|
||||
* `pr` - Personal Record achieved during workout. Format: "kind:pubkey:d-tag,metric,value". Used to track when a user achieves their best performance for a given exercise and metric (e.g., heaviest weight lifted, most reps completed, fastest time)
|
||||
* `t` - Hashtags for categorization
|
||||
|
||||
@ -154,7 +171,7 @@ Sets where technical failure was reached before completing prescribed reps. Thes
|
||||
### Circuit Workout Record
|
||||
```json
|
||||
{
|
||||
"kind": 33403,
|
||||
"kind": 1301,
|
||||
"content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.",
|
||||
"tags": [
|
||||
["d", "workout-20250128"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user