nip update and WIP social tab

This commit is contained in:
DocNR 2025-03-01 13:43:42 -05:00
parent 15c973f333
commit dca4ef5b33
13 changed files with 733 additions and 137 deletions

View File

@ -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: {

View File

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

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

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

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

View File

@ -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>

View File

@ -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(
{

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

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

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

View File

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

View File

@ -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"],