mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-03 15:52:06 +00:00
Add Profile Tab enhancements and Terms of Service modal
This commit is contained in:
parent
329154fc3d
commit
e4aa59a07e
25
CHANGELOG.md
25
CHANGELOG.md
@ -5,6 +5,31 @@ All notable changes to the POWR project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
# Changelog - March 22, 2025
|
||||
|
||||
## Added
|
||||
- Enhanced Profile Tab with new features
|
||||
- Implemented tabbed interface with Overview, Activity, Progress, and Settings
|
||||
- Added Terms of Service modal with comprehensive content
|
||||
- Created dedicated settings screen with improved organization
|
||||
- Added dark mode toggle in settings
|
||||
- Implemented proper text visibility in both light and dark modes
|
||||
- Added Nostr publishing settings with clear explanations
|
||||
- Created analytics service for workout progress tracking
|
||||
- Added progress visualization with charts and statistics
|
||||
- Implemented activity feed for personal workout history
|
||||
|
||||
## Fixed
|
||||
- Dark mode text visibility issues
|
||||
- Added explicit text-foreground classes to ensure visibility
|
||||
- Updated button variants to use purple for better contrast
|
||||
- Fixed modal content visibility in dark mode
|
||||
- Improved component styling for consistent appearance
|
||||
- TypeScript errors in navigation
|
||||
- Fixed router.push path format in SettingsDrawer
|
||||
- Updated import paths for better type safety
|
||||
- Improved component props typing
|
||||
|
||||
# Changelog - March 20, 2025
|
||||
|
||||
## Improved
|
||||
|
@ -51,7 +51,7 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Home size={size} color={color} />
|
||||
<User size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@ -97,4 +97,4 @@ export default function TabLayout() {
|
||||
<ActiveWorkoutBar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { Workout } from '@/types/workout';
|
||||
import { format, isSameDay } from 'date-fns';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WorkoutHistoryService } from '@/lib/db/services/WorkoutHIstoryService';
|
||||
import { WorkoutHistoryService } from '@/lib/db/services/WorkoutHistoryService';
|
||||
import WorkoutCard from '@/components/workout/WorkoutCard';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react-native';
|
||||
|
||||
@ -378,4 +378,4 @@ export default function CalendarScreen() {
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { Text } from '@/components/ui/text';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { format } from 'date-fns';
|
||||
import { WorkoutHistoryService } from '@/lib/db/services/WorkoutHIstoryService';
|
||||
import { WorkoutHistoryService } from '@/lib/db/services/WorkoutHistoryService';
|
||||
import WorkoutCard from '@/components/workout/WorkoutCard';
|
||||
|
||||
// Mock data for when database tables aren't yet created
|
||||
@ -157,4 +157,4 @@ export default function HistoryScreen() {
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,191 +0,0 @@
|
||||
// app/(tabs)/profile.tsx
|
||||
import { View, ScrollView, ImageBackground } from 'react-native';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings, LogIn, Bell } from 'lucide-react-native';
|
||||
import { H1 } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import Header from '@/components/Header';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { currentUser, isAuthenticated } = useNDKCurrentUser();
|
||||
const [profileImageUrl, setProfileImageUrl] = useState<string | undefined>(undefined);
|
||||
const [bannerImageUrl, setBannerImageUrl] = useState<string | undefined>(undefined);
|
||||
const [aboutText, setAboutText] = useState<string | undefined>(undefined);
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
|
||||
const displayName = isAuthenticated
|
||||
? (currentUser?.profile?.displayName || currentUser?.profile?.name || 'Nostr User')
|
||||
: 'Guest User';
|
||||
|
||||
const username = isAuthenticated
|
||||
? (currentUser?.profile?.nip05 || '@user')
|
||||
: '@guest';
|
||||
|
||||
// Reset profile data when authentication state changes
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setProfileImageUrl(undefined);
|
||||
setBannerImageUrl(undefined);
|
||||
setAboutText(undefined);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Extract profile data from Nostr profile
|
||||
useEffect(() => {
|
||||
if (currentUser?.profile) {
|
||||
console.log('Profile data:', currentUser.profile);
|
||||
|
||||
// Extract image URL
|
||||
const imageUrl = currentUser.profile.image ||
|
||||
currentUser.profile.picture ||
|
||||
(currentUser.profile as any).avatar ||
|
||||
undefined;
|
||||
setProfileImageUrl(imageUrl);
|
||||
|
||||
// Extract banner URL
|
||||
const bannerUrl = currentUser.profile.banner ||
|
||||
(currentUser.profile as any).background ||
|
||||
undefined;
|
||||
setBannerImageUrl(bannerUrl);
|
||||
|
||||
// Extract about text
|
||||
const about = currentUser.profile.about ||
|
||||
(currentUser.profile as any).description ||
|
||||
undefined;
|
||||
setAboutText(about);
|
||||
}
|
||||
}, [currentUser?.profile]);
|
||||
|
||||
// Show different UI when not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
useLogo={true}
|
||||
rightElement={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => console.log('Open notifications')}
|
||||
>
|
||||
<View className="relative">
|
||||
<Bell size={24} className="text-primary" />
|
||||
<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 p-6">
|
||||
<View className="items-center mb-8">
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
fallback="G"
|
||||
className="mb-4"
|
||||
isInteractive={false}
|
||||
/>
|
||||
<H1 className="text-xl font-semibold mb-1">Guest User</H1>
|
||||
<Text className="text-muted-foreground mb-6">Not logged in</Text>
|
||||
<Text className="text-center text-muted-foreground mb-8">
|
||||
Login with your Nostr private key to view and manage your profile.
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => setIsLoginSheetOpen(true)}
|
||||
className="px-6"
|
||||
>
|
||||
<Text>Login with Nostr</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header useLogo={true} showNotifications={true} />
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20
|
||||
}}
|
||||
>
|
||||
{/* Banner Image */}
|
||||
<View className="w-full h-40 relative">
|
||||
{bannerImageUrl ? (
|
||||
<ImageBackground
|
||||
source={{ uri: bannerImageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View className="absolute inset-0 bg-black/20" />
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View className="w-full h-full bg-accent" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Profile Avatar and Name - positioned to overlap the banner */}
|
||||
<View className="items-center -mt-16 pb-6">
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
uri={profileImageUrl}
|
||||
fallback={displayName.charAt(0)}
|
||||
className="mb-4 border-4 border-background"
|
||||
isInteractive={false}
|
||||
/>
|
||||
<H1 className="text-xl font-semibold mb-1">{displayName}</H1>
|
||||
<Text className="text-muted-foreground mb-4">{username}</Text>
|
||||
|
||||
{/* About section */}
|
||||
{aboutText && (
|
||||
<View className="px-6 py-2 w-full">
|
||||
<Text className="text-center text-foreground">{aboutText}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Stats */}
|
||||
<View className="flex-row justify-around px-4 py-6 bg-card mx-4 rounded-lg">
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">24</Text>
|
||||
<Text className="text-muted-foreground">Workouts</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">12</Text>
|
||||
<Text className="text-muted-foreground">Templates</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">3</Text>
|
||||
<Text className="text-muted-foreground">Programs</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="p-4 gap-2">
|
||||
<Button variant="outline" className="mb-2">
|
||||
<Text>Edit Profile</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="mb-2">
|
||||
<Text>Account Settings</Text>
|
||||
</Button>
|
||||
<Button variant="outline" className="mb-2">
|
||||
<Text>Preferences</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
86
app/(tabs)/profile/_layout.tsx
Normal file
86
app/(tabs)/profile/_layout.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
// app/(tabs)/profile/_layout.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import Header from '@/components/Header';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
import OverviewScreen from './overview';
|
||||
import ActivityScreen from './activity';
|
||||
import ProgressScreen from './progress';
|
||||
import SettingsScreen from './settings';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
|
||||
const Tab = createMaterialTopTabNavigator();
|
||||
|
||||
export default function ProfileTabLayout() {
|
||||
const theme = useTheme() as CustomTheme;
|
||||
const { isAuthenticated } = useNDKCurrentUser();
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
useLogo={true}
|
||||
showNotifications={true}
|
||||
/>
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="overview"
|
||||
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="overview"
|
||||
component={OverviewScreen}
|
||||
options={{ title: 'Profile' }}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="activity"
|
||||
component={ActivityScreen}
|
||||
options={{ title: 'Activity' }}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="progress"
|
||||
component={ProgressScreen}
|
||||
options={{ title: 'Progress' }}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: 'Settings' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
217
app/(tabs)/profile/activity.tsx
Normal file
217
app/(tabs)/profile/activity.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
// app/(tabs)/profile/activity.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAnalytics } from '@/lib/hooks/useAnalytics';
|
||||
import { PersonalRecord } from '@/lib/services/AnalyticsService';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useWorkouts } from '@/lib/hooks/useWorkouts';
|
||||
import { useTemplates } from '@/lib/hooks/useTemplates';
|
||||
import WorkoutCard from '@/components/workout/WorkoutCard';
|
||||
import { Dumbbell, BarChart2, Award, Calendar } from 'lucide-react-native';
|
||||
|
||||
export default function ActivityScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { isAuthenticated } = useNDKCurrentUser();
|
||||
const analytics = useAnalytics();
|
||||
const { workouts, loading: workoutsLoading } = useWorkouts();
|
||||
const { templates, loading: templatesLoading } = useTemplates();
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Stats
|
||||
const completedWorkouts = workouts?.filter(w => w.isCompleted)?.length || 0;
|
||||
const totalTemplates = templates?.length || 0;
|
||||
const totalPrograms = 0; // Placeholder for programs count
|
||||
|
||||
// Load personal records
|
||||
useEffect(() => {
|
||||
async function loadRecords() {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 3);
|
||||
setRecords(personalRecords);
|
||||
} catch (error) {
|
||||
console.error('Error loading personal records:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadRecords();
|
||||
}, [isAuthenticated, analytics]);
|
||||
|
||||
// Show different UI when not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="text-center text-muted-foreground mb-8">
|
||||
Login with your Nostr private key to view your activity and stats.
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => setIsLoginSheetOpen(true)}
|
||||
className="px-6"
|
||||
>
|
||||
<Text className="text-white">Login with Nostr</Text>
|
||||
</Button>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || workoutsLoading || templatesLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20
|
||||
}}
|
||||
>
|
||||
{/* Stats Cards - Row 1 */}
|
||||
<View className="flex-row px-4 pt-4">
|
||||
<View className="flex-1 pr-2">
|
||||
<Card>
|
||||
<CardContent className="p-4 items-center justify-center">
|
||||
<Dumbbell size={24} className="text-primary mb-2" />
|
||||
<Text className="text-2xl font-bold">{completedWorkouts}</Text>
|
||||
<Text className="text-muted-foreground">Workouts</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 pl-2">
|
||||
<Card>
|
||||
<CardContent className="p-4 items-center justify-center">
|
||||
<Calendar size={24} className="text-primary mb-2" />
|
||||
<Text className="text-2xl font-bold">{totalTemplates}</Text>
|
||||
<Text className="text-muted-foreground">Templates</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Cards - Row 2 */}
|
||||
<View className="flex-row px-4 pt-4 mb-4">
|
||||
<View className="flex-1 pr-2">
|
||||
<Card>
|
||||
<CardContent className="p-4 items-center justify-center">
|
||||
<BarChart2 size={24} className="text-primary mb-2" />
|
||||
<Text className="text-2xl font-bold">{totalPrograms}</Text>
|
||||
<Text className="text-muted-foreground">Programs</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 pl-2">
|
||||
<Card>
|
||||
<CardContent className="p-4 items-center justify-center">
|
||||
<Award size={24} className="text-primary mb-2" />
|
||||
<Text className="text-2xl font-bold">{records.length}</Text>
|
||||
<Text className="text-muted-foreground">PRs</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Personal Records */}
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text className="text-lg font-semibold">Personal Records</Text>
|
||||
<Pressable onPress={() => router.push('/profile/progress')}>
|
||||
<Text className="text-primary">View All</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<Text className="text-muted-foreground text-center py-4">
|
||||
No personal records yet. Complete more workouts to see your progress.
|
||||
</Text>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<View key={record.id} className="py-2 border-b border-border">
|
||||
<Text className="font-medium">{record.exerciseName}</Text>
|
||||
<Text>{record.value} {record.unit} × {record.reps} reps</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(record.date), { addSuffix: true })}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Workouts */}
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text className="text-lg font-semibold">Recent Workouts</Text>
|
||||
<Pressable onPress={() => router.push('/history/workoutHistory')}>
|
||||
<Text className="text-primary">View All</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{workouts && workouts.length > 0 ? (
|
||||
workouts
|
||||
.filter(workout => workout.isCompleted)
|
||||
.slice(0, 2)
|
||||
.map(workout => (
|
||||
<View key={workout.id} className="mb-3">
|
||||
<WorkoutCard
|
||||
workout={workout}
|
||||
showDate={true}
|
||||
showExercises={false}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text className="text-muted-foreground text-center py-4">
|
||||
No recent workouts. Complete a workout to see it here.
|
||||
</Text>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View className="p-4 gap-2">
|
||||
<Button
|
||||
variant="purple"
|
||||
className="mb-2"
|
||||
onPress={() => router.push('/')}
|
||||
>
|
||||
<Text className="text-white">Start a Workout</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
onPress={() => router.push('/profile/progress')}
|
||||
>
|
||||
<Text>View Progress</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
259
app/(tabs)/profile/overview.tsx
Normal file
259
app/(tabs)/profile/overview.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
// app/(tabs)/profile/overview.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
||||
import EmptyFeed from '@/components/social/EmptyFeed';
|
||||
import { useUserActivityFeed } from '@/lib/hooks/useFeedHooks';
|
||||
import { AnyFeedEntry } from '@/types/feed';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { QrCode, Mail, Copy } from 'lucide-react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
// Define the conversion function for feed items
|
||||
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||
return {
|
||||
id: entry.eventId,
|
||||
type: entry.type,
|
||||
originalEvent: entry.event!,
|
||||
parsedContent: entry.content!,
|
||||
createdAt: (entry.timestamp || Date.now()) / 1000
|
||||
};
|
||||
}
|
||||
|
||||
export default function OverviewScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const theme = useTheme() as CustomTheme;
|
||||
const { currentUser, isAuthenticated } = useNDKCurrentUser();
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
const {
|
||||
entries,
|
||||
loading,
|
||||
resetFeed,
|
||||
hasContent
|
||||
} = useUserActivityFeed();
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Profile data
|
||||
const profileImageUrl = currentUser?.profile?.image ||
|
||||
currentUser?.profile?.picture ||
|
||||
(currentUser?.profile as any)?.avatar;
|
||||
|
||||
const bannerImageUrl = currentUser?.profile?.banner ||
|
||||
(currentUser?.profile as any)?.background;
|
||||
|
||||
const displayName = isAuthenticated
|
||||
? (currentUser?.profile?.displayName || currentUser?.profile?.name || 'Nostr User')
|
||||
: 'Guest User';
|
||||
|
||||
const username = isAuthenticated
|
||||
? (currentUser?.profile?.nip05 || '@user')
|
||||
: '@guest';
|
||||
|
||||
const aboutText = currentUser?.profile?.about ||
|
||||
(currentUser?.profile as any)?.description;
|
||||
|
||||
const pubkey = currentUser?.pubkey;
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await resetFeed();
|
||||
// Add a slight delay to ensure the UI updates
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
} catch (error) {
|
||||
console.error('Error refreshing feed:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [resetFeed]);
|
||||
|
||||
// Handle post selection
|
||||
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
|
||||
// Just log the entry info for now
|
||||
console.log(`Selected ${entry.type}:`, entry);
|
||||
}, []);
|
||||
|
||||
// Copy pubkey to clipboard
|
||||
const copyPubkey = useCallback(() => {
|
||||
if (pubkey) {
|
||||
// Simple alert instead of clipboard functionality
|
||||
Alert.alert('Pubkey', pubkey, [
|
||||
{ text: 'OK' }
|
||||
]);
|
||||
console.log('Pubkey shown to user');
|
||||
}
|
||||
}, [pubkey]);
|
||||
|
||||
// Show QR code alert
|
||||
const showQRCode = useCallback(() => {
|
||||
Alert.alert('QR Code', 'QR Code functionality will be implemented soon', [
|
||||
{ text: 'OK' }
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Memoize render item function
|
||||
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
|
||||
<EnhancedSocialPost
|
||||
item={convertToLegacyFeedItem(item)}
|
||||
onPress={() => handlePostPress(item)}
|
||||
/>
|
||||
), [handlePostPress]);
|
||||
|
||||
// Show different UI when not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="text-center text-muted-foreground mb-8">
|
||||
Login with your Nostr private key to view your profile and posts.
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => setIsLoginSheetOpen(true)}
|
||||
className="px-6"
|
||||
>
|
||||
<Text className="text-white">Login with Nostr</Text>
|
||||
</Button>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile header component
|
||||
const ProfileHeader = useCallback(() => (
|
||||
<View>
|
||||
{/* Banner Image */}
|
||||
<View className="w-full h-40 relative">
|
||||
{bannerImageUrl ? (
|
||||
<ImageBackground
|
||||
source={{ uri: bannerImageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View className="absolute inset-0 bg-black/20" />
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View className="w-full h-full bg-gradient-to-b from-primary/80 to-primary/30" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="px-4 -mt-16 pb-2">
|
||||
<View className="flex-row items-end mb-4">
|
||||
{/* Left side - Avatar */}
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
uri={profileImageUrl}
|
||||
fallback={displayName.charAt(0)}
|
||||
className="mr-4 border-4 border-background"
|
||||
isInteractive={false}
|
||||
style={{ width: 90, height: 90 }}
|
||||
/>
|
||||
|
||||
{/* Action buttons - positioned to the right */}
|
||||
<View className="flex-row ml-auto mb-2">
|
||||
<TouchableOpacity
|
||||
className="w-10 h-10 items-center justify-center rounded-md bg-muted mr-2"
|
||||
onPress={showQRCode}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="w-10 h-10 items-center justify-center rounded-md bg-muted mr-2"
|
||||
onPress={copyPubkey}
|
||||
>
|
||||
<Copy size={18} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="px-4 h-10 items-center justify-center rounded-md bg-muted"
|
||||
onPress={() => router.push('/profile/settings')}
|
||||
>
|
||||
<Text className="font-medium">Edit Profile</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Profile info */}
|
||||
<View>
|
||||
<Text className="text-xl font-bold">{displayName}</Text>
|
||||
<Text className="text-muted-foreground mb-2">{username}</Text>
|
||||
|
||||
{/* Follower stats */}
|
||||
<View className="flex-row mb-2">
|
||||
<TouchableOpacity className="mr-4">
|
||||
<Text>
|
||||
<Text className="font-bold">2,003</Text>
|
||||
<Text className="text-muted-foreground"> following</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity>
|
||||
<Text>
|
||||
<Text className="font-bold">4,764</Text>
|
||||
<Text className="text-muted-foreground"> followers</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* About text */}
|
||||
{aboutText && (
|
||||
<Text className="mb-3">{aboutText}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View className="h-px bg-border w-full mt-2" />
|
||||
</View>
|
||||
</View>
|
||||
), [displayName, username, profileImageUrl, aboutText, pubkey, theme.colors.text, router, showQRCode, copyPubkey]);
|
||||
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<FlatList
|
||||
data={entries}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={<ProfileHeader />}
|
||||
ListEmptyComponent={
|
||||
<View className="px-4 py-8">
|
||||
<EmptyFeed message="No posts yet. Share your workouts or create posts to see them here." />
|
||||
</View>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
flexGrow: entries.length === 0 ? 1 : undefined
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
248
app/(tabs)/profile/progress.tsx
Normal file
248
app/(tabs)/profile/progress.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
// app/(tabs)/profile/progress.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { useAnalytics } from '@/lib/hooks/useAnalytics';
|
||||
import { WorkoutStats, PersonalRecord } from '@/lib/services/AnalyticsService';
|
||||
|
||||
// Period selector component
|
||||
function PeriodSelector({ period, setPeriod }: {
|
||||
period: 'week' | 'month' | 'year' | 'all',
|
||||
setPeriod: (period: 'week' | 'month' | 'year' | 'all') => void
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row justify-center my-4">
|
||||
<Button
|
||||
variant={period === 'week' ? 'purple' : 'outline'}
|
||||
size="sm"
|
||||
className="mx-1"
|
||||
onPress={() => setPeriod('week')}
|
||||
>
|
||||
<Text className={period === 'week' ? 'text-white' : 'text-foreground'}>Week</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'month' ? 'purple' : 'outline'}
|
||||
size="sm"
|
||||
className="mx-1"
|
||||
onPress={() => setPeriod('month')}
|
||||
>
|
||||
<Text className={period === 'month' ? 'text-white' : 'text-foreground'}>Month</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'year' ? 'purple' : 'outline'}
|
||||
size="sm"
|
||||
className="mx-1"
|
||||
onPress={() => setPeriod('year')}
|
||||
>
|
||||
<Text className={period === 'year' ? 'text-white' : 'text-foreground'}>Year</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'all' ? 'purple' : 'outline'}
|
||||
size="sm"
|
||||
className="mx-1"
|
||||
onPress={() => setPeriod('all')}
|
||||
>
|
||||
<Text className={period === 'all' ? 'text-white' : 'text-foreground'}>All</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Format duration in hours and minutes
|
||||
function formatDuration(milliseconds: number): string {
|
||||
const hours = Math.floor(milliseconds / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export default function ProgressScreen() {
|
||||
const { isAuthenticated } = useNDKCurrentUser();
|
||||
const analytics = useAnalytics();
|
||||
const [period, setPeriod] = useState<'week' | 'month' | 'year' | 'all'>('month');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<WorkoutStats | null>(null);
|
||||
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
||||
|
||||
// Load workout statistics when period changes
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const workoutStats = await analytics.getWorkoutStats(period);
|
||||
setStats(workoutStats);
|
||||
|
||||
// Load personal records
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 5);
|
||||
setRecords(personalRecords);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
}, [isAuthenticated, period, analytics]);
|
||||
|
||||
// Workout frequency chart
|
||||
const WorkoutFrequencyChart = () => {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
return (
|
||||
<View className="h-40 bg-muted rounded-lg items-center justify-center">
|
||||
<Text className="text-muted-foreground">Workout Frequency Chart</Text>
|
||||
<View className="flex-row justify-evenly w-full mt-2">
|
||||
{stats?.frequencyByDay.map((count, index) => (
|
||||
<View key={index} className="items-center">
|
||||
<View
|
||||
style={{
|
||||
height: count * 8,
|
||||
width: 20,
|
||||
backgroundColor: 'hsl(var(--purple))',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
<Text className="text-xs text-muted-foreground mt-1">{days[index]}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Exercise distribution chart
|
||||
const ExerciseDistributionChart = () => {
|
||||
// Sample exercise names for demonstration
|
||||
const exerciseNames = [
|
||||
'Bench Press', 'Squat', 'Deadlift', 'Pull-up', 'Shoulder Press'
|
||||
];
|
||||
|
||||
// Convert exercise distribution to percentages
|
||||
const exerciseDistribution = stats?.exerciseDistribution || {};
|
||||
const total = Object.values(exerciseDistribution).reduce((sum, count) => sum + count, 0) || 1;
|
||||
const percentages = Object.entries(exerciseDistribution).reduce((acc, [id, count]) => {
|
||||
acc[id] = Math.round((count / total) * 100);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Take top 5 exercises
|
||||
const topExercises = Object.entries(percentages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<View className="h-40 bg-muted rounded-lg items-center justify-center">
|
||||
<Text className="text-muted-foreground">Exercise Distribution</Text>
|
||||
<View className="flex-row justify-evenly w-full mt-2">
|
||||
{topExercises.map(([id, percentage], index) => (
|
||||
<View key={index} className="items-center mx-1">
|
||||
<View
|
||||
style={{
|
||||
height: percentage * 1.5,
|
||||
width: 20,
|
||||
backgroundColor: `hsl(${index * 50}, 70%, 50%)`,
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
<Text className="text-xs text-muted-foreground mt-1 text-center">
|
||||
{exerciseNames[index % exerciseNames.length].substring(0, 8)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="text-center text-muted-foreground">
|
||||
Log in to view your progress
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<PeriodSelector period={period} setPeriod={setPeriod} />
|
||||
|
||||
{/* Workout Summary */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-2">Workout Summary</Text>
|
||||
<Text className="mb-1">Workouts: {stats?.workoutCount || 0}</Text>
|
||||
<Text className="mb-1">Total Time: {formatDuration(stats?.totalDuration || 0)}</Text>
|
||||
<Text className="mb-1">Total Volume: {(stats?.totalVolume || 0).toLocaleString()} lb</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workout Frequency Chart */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-2">Workout Frequency</Text>
|
||||
<WorkoutFrequencyChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Muscle Group Distribution */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-2">Exercise Distribution</Text>
|
||||
<ExerciseDistributionChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personal Records */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-2">Personal Records</Text>
|
||||
{records.length === 0 ? (
|
||||
<Text className="text-muted-foreground text-center py-4">
|
||||
No personal records yet. Complete more workouts to see your progress.
|
||||
</Text>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<View key={record.id} className="py-2 border-b border-border">
|
||||
<Text className="font-medium">{record.exerciseName}</Text>
|
||||
<Text>{record.value} {record.unit} × {record.reps} reps</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{new Date(record.date).toLocaleDateString()}
|
||||
</Text>
|
||||
{record.previousRecord && (
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
Previous: {record.previousRecord.value} {record.unit}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Note about future implementation */}
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Note: This is a placeholder UI. In the future, this tab will display real analytics based on your workout history.
|
||||
</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
149
app/(tabs)/profile/settings.tsx
Normal file
149
app/(tabs)/profile/settings.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
// app/(tabs)/profile/settings.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, Switch, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useNDKCurrentUser, useNDKAuth } from '@/lib/hooks/useNDK';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
import TermsOfServiceModal from '@/components/TermsOfServiceModal';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
||||
import { ChevronRight } from 'lucide-react-native';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme() as CustomTheme;
|
||||
const { colorScheme, toggleColorScheme } = useColorScheme();
|
||||
const { currentUser, isAuthenticated } = useNDKCurrentUser();
|
||||
const { logout } = useNDKAuth();
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
const [isTermsModalVisible, setIsTermsModalVisible] = useState(false);
|
||||
|
||||
// Show different UI when not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="text-center text-muted-foreground mb-8">
|
||||
Login with your Nostr private key to access settings.
|
||||
</Text>
|
||||
<Button
|
||||
variant="purple"
|
||||
onPress={() => setIsLoginSheetOpen(true)}
|
||||
className="px-6"
|
||||
>
|
||||
<Text className="text-white">Login with Nostr</Text>
|
||||
</Button>
|
||||
|
||||
{/* NostrLoginSheet */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={() => setIsLoginSheetOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20
|
||||
}}
|
||||
>
|
||||
{/* Account Settings */}
|
||||
<Card className="mx-4 mt-4 mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-4">Account Settings</Text>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2 border-b border-border">
|
||||
<Text>Nostr Publishing</Text>
|
||||
<Text className="text-muted-foreground">Public</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2 border-b border-border">
|
||||
<Text>Local Storage</Text>
|
||||
<Text className="text-muted-foreground">Enabled</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2">
|
||||
<Text>Connected Relays</Text>
|
||||
<Text className="text-muted-foreground">5</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* App Settings */}
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-4">App Settings</Text>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2 border-b border-border">
|
||||
<Text>Dark Mode</Text>
|
||||
<Switch
|
||||
value={colorScheme === 'dark'}
|
||||
onValueChange={toggleColorScheme}
|
||||
trackColor={{ false: '#767577', true: theme.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2 border-b border-border">
|
||||
<Text>Notifications</Text>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{ false: '#767577', true: theme.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2">
|
||||
<Text>Units</Text>
|
||||
<Text className="text-muted-foreground">Metric (kg)</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* About */}
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold mb-4">About</Text>
|
||||
|
||||
<View className="flex-row justify-between items-center py-2 border-b border-border">
|
||||
<Text>Version</Text>
|
||||
<Text className="text-muted-foreground">1.0.0</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row justify-between items-center py-2"
|
||||
onPress={() => setIsTermsModalVisible(true)}
|
||||
>
|
||||
<Text>Terms of Service</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="text-primary mr-1">View</Text>
|
||||
<ChevronRight size={16} color={theme.colors.primary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Terms of Service Modal */}
|
||||
<TermsOfServiceModal
|
||||
visible={isTermsModalVisible}
|
||||
onClose={() => setIsTermsModalVisible(false)}
|
||||
/>
|
||||
|
||||
|
||||
{/* Logout Button */}
|
||||
<View className="mx-4 mt-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onPress={logout}
|
||||
className="w-full"
|
||||
>
|
||||
<Text className="text-white">Logout</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
75
app/(tabs)/profile/terms.tsx
Normal file
75
app/(tabs)/profile/terms.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// app/(tabs)/profile/terms.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronLeft } from 'lucide-react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
export default function TermsOfServiceScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const currentDate = format(new Date(), 'MMMM d, yyyy');
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<View
|
||||
className="flex-row items-center px-4 border-b border-border"
|
||||
style={{ paddingTop: insets.top, height: insets.top + 56 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="mr-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} className="text-foreground" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xl font-bold text-foreground">Terms of Service</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
paddingTop: 16
|
||||
}}
|
||||
>
|
||||
<Text className="text-lg font-bold mb-4 text-foreground">POWR App Terms of Service</Text>
|
||||
|
||||
<Text className="mb-6 text-foreground">
|
||||
POWR is a local-first fitness tracking application that integrates with the Nostr protocol.
|
||||
</Text>
|
||||
|
||||
<Text className="text-base font-semibold mb-2 text-foreground">Data Storage and Privacy</Text>
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-foreground">• POWR stores your workout data, exercise templates, and preferences locally on your device.</Text>
|
||||
<Text className="mb-2 text-foreground">• We do not collect, store, or track any personal data on our servers.</Text>
|
||||
<Text className="mb-2 text-foreground">• Any content you choose to publish to Nostr relays (such as workouts or templates) will be publicly available to anyone with access to those relays. Think of Nostr posts as public broadcasts.</Text>
|
||||
<Text className="mb-2 text-foreground">• Your private keys are stored locally on your device and are never transmitted to us.</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-base font-semibold mb-2 text-foreground">User Responsibility</Text>
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-foreground">• You are responsible for safeguarding your private keys.</Text>
|
||||
<Text className="mb-2 text-foreground">• You are solely responsible for any content you publish to Nostr relays.</Text>
|
||||
<Text className="mb-2 text-foreground">• Exercise caution when sharing personal information through public Nostr posts.</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-base font-semibold mb-2 text-foreground">Fitness Disclaimer</Text>
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-foreground">• POWR provides fitness tracking tools, not medical advice. Consult a healthcare professional before starting any fitness program.</Text>
|
||||
<Text className="mb-2 text-foreground">• You are solely responsible for any injuries or health issues that may result from exercises tracked using this app.</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-base font-semibold mb-2 text-foreground">Changes to Terms</Text>
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-foreground">We may update these terms from time to time. Continued use of the app constitutes acceptance of any changes.</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-muted-foreground mt-4">
|
||||
Last Updated: {currentDate}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
@ -101,7 +101,7 @@ export default function SettingsDrawer() {
|
||||
// Go to the profile tab which should have login functionality
|
||||
closeDrawer();
|
||||
setTimeout(() => {
|
||||
router.push("/(tabs)/profile");
|
||||
router.push("/");
|
||||
}, 300);
|
||||
};
|
||||
|
||||
@ -531,4 +531,4 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
paddingBottom: Platform.OS === 'ios' ? 16 : 16,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
173
components/TermsOfServiceModal.tsx
Normal file
173
components/TermsOfServiceModal.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
// components/TermsOfServiceModal.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, Modal, TouchableOpacity, StyleSheet, Text } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { format } from 'date-fns';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
||||
|
||||
interface TermsOfServiceModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TermsOfServiceModal({ visible, onClose }: TermsOfServiceModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
const currentDate = format(new Date(), 'MMMM d, yyyy');
|
||||
|
||||
const textColor = isDarkColorScheme ? '#FFFFFF' : '#000000';
|
||||
const backgroundColor = isDarkColorScheme ? '#1c1c1e' : '#FFFFFF';
|
||||
const mutedTextColor = isDarkColorScheme ? '#A1A1A1' : '#6B7280';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={[styles.modalContent, { backgroundColor }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: textColor }]}>Terms of Service</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<X size={24} color={textColor} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<Text style={[styles.heading, { color: textColor }]}>
|
||||
POWR App Terms of Service
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.paragraph, { color: textColor }]}>
|
||||
POWR is a local-first fitness tracking application that integrates with the Nostr protocol.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: textColor }]}>
|
||||
Data Storage and Privacy
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• POWR stores your workout data, exercise templates, and preferences locally on your device.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• We do not collect, store, or track any personal data on our servers.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• Any content you choose to publish to Nostr relays (such as workouts or templates) will be publicly available to anyone with access to those relays. Think of Nostr posts as public broadcasts.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• Your private keys are stored locally on your device and are never transmitted to us.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: textColor }]}>
|
||||
User Responsibility
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• You are responsible for safeguarding your private keys.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• You are solely responsible for any content you publish to Nostr relays.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• Exercise caution when sharing personal information through public Nostr posts.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: textColor }]}>
|
||||
Fitness Disclaimer
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• POWR provides fitness tracking tools, not medical advice. Consult a healthcare professional before starting any fitness program.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: textColor }]}>
|
||||
• You are solely responsible for any injuries or health issues that may result from exercises tracked using this app.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: textColor }]}>
|
||||
Changes to Terms
|
||||
</Text>
|
||||
<Text style={[styles.paragraph, { color: textColor }]}>
|
||||
We may update these terms from time to time. Continued use of the app constitutes acceptance of any changes.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.lastUpdated, { color: mutedTextColor }]}>
|
||||
Last Updated: {currentDate}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
variant="purple"
|
||||
onPress={onClose}
|
||||
className="mt-4"
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF' }}>Close</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxHeight: '80%',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
marginBottom: 16,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bulletPoint: {
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
lastUpdated: {
|
||||
fontSize: 12,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
175
components/sheets/TermsOfServiceSheet.tsx
Normal file
175
components/sheets/TermsOfServiceSheet.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
// components/sheets/TermsOfServiceSheet.tsx
|
||||
import React from 'react';
|
||||
import { View, ScrollView, Modal, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { format } from 'date-fns';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { useColorScheme } from '@/lib/theme/useColorScheme';
|
||||
|
||||
interface TermsOfServiceSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TermsOfServiceSheet({ open, onClose }: TermsOfServiceSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
const currentDate = format(new Date(), 'MMMM d, yyyy');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent={true}
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={[
|
||||
styles.modalContent,
|
||||
{ backgroundColor: isDarkColorScheme ? '#1c1c1e' : '#ffffff' }
|
||||
]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Terms of Service</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<X size={24} color={isDarkColorScheme ? '#ffffff' : '#000000'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<Text style={[styles.heading, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
POWR App Terms of Service
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.paragraph, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
POWR is a local-first fitness tracking application that integrates with the Nostr protocol.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
Data Storage and Privacy
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• POWR stores your workout data, exercise templates, and preferences locally on your device.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• We do not collect, store, or track any personal data on our servers.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• Any content you choose to publish to Nostr relays (such as workouts or templates) will be publicly available to anyone with access to those relays. Think of Nostr posts as public broadcasts.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• Your private keys are stored locally on your device and are never transmitted to us.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
User Responsibility
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• You are responsible for safeguarding your private keys.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• You are solely responsible for any content you publish to Nostr relays.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• Exercise caution when sharing personal information through public Nostr posts.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
Fitness Disclaimer
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• POWR provides fitness tracking tools, not medical advice. Consult a healthcare professional before starting any fitness program.
|
||||
</Text>
|
||||
<Text style={[styles.bulletPoint, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
• You are solely responsible for any injuries or health issues that may result from exercises tracked using this app.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
Changes to Terms
|
||||
</Text>
|
||||
<Text style={[styles.paragraph, { color: isDarkColorScheme ? '#ffffff' : '#000000' }]}>
|
||||
We may update these terms from time to time. Continued use of the app constitutes acceptance of any changes.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.lastUpdated, { color: isDarkColorScheme ? '#a1a1a1' : '#6b7280' }]}>
|
||||
Last Updated: {currentDate}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
variant="purple"
|
||||
onPress={onClose}
|
||||
className="mt-4"
|
||||
>
|
||||
<Text className="text-white">Close</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxHeight: '80%',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
marginBottom: 16,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bulletPoint: {
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
lastUpdated: {
|
||||
fontSize: 12,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
147
docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md
Normal file
147
docs/design/ProfileTab/ProfileTabEnhancementDesignDoc.md
Normal file
@ -0,0 +1,147 @@
|
||||
# Profile Tab Enhancement Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the design and implementation of the enhanced Profile tab for the POWR app. The enhancement includes a tabbed interface with separate screens for profile overview, activity feed, progress analytics, and settings.
|
||||
|
||||
## Motivation
|
||||
|
||||
The original Profile tab was limited to displaying basic user information. With the growing social and analytics features of the app, we need a more comprehensive profile experience that:
|
||||
|
||||
1. Showcases the user's identity and achievements
|
||||
2. Displays workout activity in a social feed format
|
||||
3. Provides analytics and progress tracking
|
||||
4. Offers easy access to settings and preferences
|
||||
|
||||
By moving analytics and progress tracking to the Profile tab, we create a more cohesive user experience that focuses on personal growth and achievement.
|
||||
|
||||
## Design
|
||||
|
||||
### Tab Structure
|
||||
|
||||
The enhanced Profile tab is organized into four sub-tabs:
|
||||
|
||||
1. **Overview**: Displays user profile information, stats summary, and quick access to recent records and activity
|
||||
2. **Activity**: Shows a chronological feed of the user's workout posts
|
||||
3. **Progress**: Provides analytics and progress tracking with charts and personal records
|
||||
4. **Settings**: Contains profile editing, privacy controls, and app preferences
|
||||
|
||||
### Navigation
|
||||
|
||||
The tabs are implemented using Expo Router's `Tabs` component, with appropriate icons for each tab:
|
||||
|
||||
- Overview: User icon
|
||||
- Activity: Activity icon
|
||||
- Progress: BarChart2 icon
|
||||
- Settings: Settings icon
|
||||
|
||||
### Data Flow
|
||||
|
||||
The Profile tab components interact with several services:
|
||||
|
||||
1. **NDK Services**: For user profile data and authentication
|
||||
2. **WorkoutService**: For accessing workout history
|
||||
3. **AnalyticsService**: For calculating statistics and progress metrics
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### New Components and Files
|
||||
|
||||
1. **Tab Layout**:
|
||||
- `app/(tabs)/profile/_layout.tsx`: Defines the tab structure and navigation
|
||||
|
||||
2. **Tab Screens**:
|
||||
- `app/(tabs)/profile/overview.tsx`: Profile information and summary
|
||||
- `app/(tabs)/profile/activity.tsx`: Workout activity feed
|
||||
- `app/(tabs)/profile/progress.tsx`: Analytics and progress tracking
|
||||
- `app/(tabs)/profile/settings.tsx`: User settings and preferences
|
||||
|
||||
3. **Services**:
|
||||
- `lib/services/AnalyticsService.ts`: Service for calculating workout statistics and progress data
|
||||
- `lib/hooks/useAnalytics.ts`: React hook for accessing the analytics service
|
||||
|
||||
### Analytics Service
|
||||
|
||||
The AnalyticsService provides methods for:
|
||||
|
||||
1. **Workout Statistics**: Calculate aggregate statistics like total workouts, duration, volume, etc.
|
||||
2. **Exercise Progress**: Track progress for specific exercises over time
|
||||
3. **Personal Records**: Identify and track personal records for exercises
|
||||
|
||||
The service is designed to work with both local and Nostr-based workout data, providing a unified view of the user's progress.
|
||||
|
||||
### Authentication Integration
|
||||
|
||||
The Profile tab is integrated with the Nostr authentication system:
|
||||
|
||||
- Unauthenticated users see a login prompt in the Overview tab
|
||||
- All tabs show appropriate UI for unauthenticated users
|
||||
- The NostrLoginSheet is accessible from the Overview tab
|
||||
|
||||
## User Experience
|
||||
|
||||
### Overview Tab
|
||||
|
||||
The Overview tab provides a comprehensive view of the user's profile:
|
||||
|
||||
- Profile picture and banner image
|
||||
- Display name and username
|
||||
- About/bio text
|
||||
- Summary statistics (workouts, templates, programs)
|
||||
- Recent personal records
|
||||
- Recent activity
|
||||
- Quick actions for profile management
|
||||
|
||||
### Activity Tab
|
||||
|
||||
The Activity tab displays the user's workout posts in a chronological feed:
|
||||
|
||||
- Each post shows the workout details
|
||||
- Posts are formatted similar to the social feed
|
||||
- Empty state for users with no activity
|
||||
|
||||
### Progress Tab
|
||||
|
||||
The Progress tab visualizes the user's fitness journey:
|
||||
|
||||
- Period selector (week, month, year, all-time)
|
||||
- Workout summary statistics
|
||||
- Workout frequency chart
|
||||
- Exercise distribution chart
|
||||
- Personal records list
|
||||
- Empty states for users with no data
|
||||
|
||||
### Settings Tab
|
||||
|
||||
The Settings tab provides access to user preferences:
|
||||
|
||||
- Profile information editing
|
||||
- Privacy settings
|
||||
- Notification preferences
|
||||
- Account management
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Workout Streaks**: Track and display workout consistency
|
||||
2. **Goal Setting**: Allow users to set and track fitness goals
|
||||
3. **Comparison Analytics**: Compare current performance with past periods
|
||||
4. **Social Integration**: Show followers/following counts and management
|
||||
5. **Achievement Badges**: Gamification elements for workout milestones
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Performance
|
||||
|
||||
- The AnalyticsService uses caching to minimize recalculations
|
||||
- Data is loaded asynchronously to keep the UI responsive
|
||||
- Charts and visualizations use efficient rendering techniques
|
||||
|
||||
### Data Privacy
|
||||
|
||||
- Analytics are calculated locally on the device
|
||||
- Sharing controls allow users to decide what data is public
|
||||
- Personal records can be selectively shared
|
||||
|
||||
## Conclusion
|
||||
|
||||
The enhanced Profile tab transforms the user experience by providing a comprehensive view of the user's identity, activity, and progress. By centralizing these features in the Profile tab, we create a more intuitive and engaging experience that encourages users to track their fitness journey and celebrate their achievements.
|
368
docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md
Normal file
368
docs/design/WorkoutTab/HistoryTabEnhancementDesignDoc.md
Normal file
@ -0,0 +1,368 @@
|
||||
# History Tab Enhancement Design Document
|
||||
|
||||
## Problem Statement
|
||||
The current History tab provides basic workout tracking but lacks filtering, detailed views, and improved visualization features that would help users better navigate and understand their training history. Additionally, the app needs to better integrate with Nostr for workout sharing and synchronization across devices. Analytics and progress tracking features, which were initially considered for the History tab, have been moved to the Profile tab as they better align with documenting personal growth.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- Enhanced workout history browsing with filtering and sorting
|
||||
- Detailed workout view with complete exercise and set information
|
||||
- Calendar view with improved visualization and interaction
|
||||
- Export and sharing capabilities for workout data
|
||||
- Search functionality across workout history
|
||||
- Differentiation between local and Nostr-published workouts
|
||||
- Ability to publish local workouts to Nostr
|
||||
- Display of Nostr workouts not stored locally
|
||||
- Integration with Profile tab for analytics and progress tracking
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Performance optimization for large workout histories
|
||||
- Responsive UI that works across device sizes
|
||||
- Offline functionality for viewing history without network
|
||||
- Consistent design language with the rest of the app
|
||||
- Accessibility compliance
|
||||
- Efficient data synchronization with Nostr relays
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Tab Structure Enhancement
|
||||
Keep the existing History/Calendar structure, focusing on enhancing these views with better filtering, search, and visualization.
|
||||
|
||||
Rationale:
|
||||
- Maintains simplicity of individual views
|
||||
- Follows established patterns in fitness apps
|
||||
- Allows specialized UI for each view type
|
||||
- Analytics and progress tracking will be moved to the Profile tab for better user context
|
||||
|
||||
### 2. Data Aggregation Strategy
|
||||
Implement a dedicated analytics service that pre-processes workout data for visualization. This service will be shared with the Profile tab's analytics features.
|
||||
|
||||
Rationale:
|
||||
- Improves performance by avoiding repeated calculations
|
||||
- Enables complex trend analysis without UI lag
|
||||
- Separates presentation logic from data processing
|
||||
- Supports both history visualization and profile analytics
|
||||
|
||||
### 3. History Visualization Approach
|
||||
Focus on providing clear, chronological views of workout history with rich filtering and search capabilities.
|
||||
|
||||
Rationale:
|
||||
- Users need to quickly find specific past workouts
|
||||
- Calendar view provides temporal context for training patterns
|
||||
- Filtering by exercise, type, and other attributes enables targeted review
|
||||
- Integration with Profile tab analytics provides deeper insights when needed
|
||||
|
||||
### 4. Nostr Integration Strategy
|
||||
Implement a tiered approach to Nostr integration, starting with basic publishing capabilities in the MVP and expanding to full synchronization in future versions.
|
||||
|
||||
Rationale:
|
||||
- Allows for incremental development and testing
|
||||
- Prioritizes most valuable features for initial release
|
||||
- Addresses core user needs first
|
||||
- Builds foundation for more advanced features
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Core Components
|
||||
|
||||
```typescript
|
||||
// Enhanced history service
|
||||
|
||||
// Enhanced workout history service
|
||||
interface EnhancedWorkoutHistoryService extends WorkoutHistoryService {
|
||||
searchWorkouts(query: string): Promise<Workout[]>;
|
||||
filterWorkouts(filters: WorkoutFilters): Promise<Workout[]>;
|
||||
getWorkoutsByExercise(exerciseId: string): Promise<Workout[]>;
|
||||
exportWorkoutHistory(format: 'csv' | 'json'): Promise<string>;
|
||||
getWorkoutStreak(): Promise<StreakData>;
|
||||
|
||||
// Nostr integration methods
|
||||
getWorkoutSyncStatus(workoutId: string): Promise<{
|
||||
isLocal: boolean;
|
||||
isPublished: boolean;
|
||||
eventId?: string;
|
||||
relayCount?: number;
|
||||
}>;
|
||||
publishWorkoutToNostr(workoutId: string): Promise<string>; // Returns event ID
|
||||
}
|
||||
|
||||
// Nostr workout service (for future expansion)
|
||||
interface NostrWorkoutHistoryService {
|
||||
// Fetch workouts from relays that aren't in local DB
|
||||
fetchNostrOnlyWorkouts(since?: Date): Promise<Workout[]>;
|
||||
|
||||
// Merge local and Nostr workouts with deduplication
|
||||
getMergedWorkoutHistory(): Promise<Workout[]>;
|
||||
|
||||
// Import a Nostr workout into local DB
|
||||
importNostrWorkoutToLocal(eventId: string): Promise<string>;
|
||||
}
|
||||
|
||||
// New data structures
|
||||
interface WorkoutStats {
|
||||
period: string;
|
||||
workoutCount: number;
|
||||
totalVolume: number;
|
||||
totalDuration: number;
|
||||
averageIntensity: number;
|
||||
exerciseDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ProgressPoint {
|
||||
date: number;
|
||||
value: number;
|
||||
workoutId: string;
|
||||
}
|
||||
|
||||
interface WorkoutFilters {
|
||||
type?: TemplateType[];
|
||||
dateRange?: { start: Date; end: Date };
|
||||
exercises?: string[];
|
||||
tags?: string[];
|
||||
source?: ('local' | 'nostr' | 'both')[];
|
||||
}
|
||||
|
||||
// Enhanced WorkoutCard props
|
||||
interface EnhancedWorkoutCardProps extends WorkoutCardProps {
|
||||
source: 'local' | 'nostr' | 'both';
|
||||
publishStatus?: {
|
||||
isPublished: boolean;
|
||||
relayCount: number;
|
||||
lastPublished?: number;
|
||||
};
|
||||
onShare?: () => void;
|
||||
onImport?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Database Schema Updates
|
||||
|
||||
```sql
|
||||
-- Fix typo in service name first (WorkoutHIstoryService -> WorkoutHistoryService)
|
||||
|
||||
-- Add Nostr-related fields to completed_workouts table
|
||||
ALTER TABLE completed_workouts ADD COLUMN nostr_event_id TEXT;
|
||||
ALTER TABLE completed_workouts ADD COLUMN nostr_published_at INTEGER;
|
||||
ALTER TABLE completed_workouts ADD COLUMN nostr_relay_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE completed_workouts ADD COLUMN source TEXT DEFAULT 'local';
|
||||
|
||||
-- Create table for tracking Nostr-only workouts (future expansion)
|
||||
CREATE TABLE IF NOT EXISTS nostr_workouts (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
sig TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
relay_count INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
- SQLite database for local storage
|
||||
- Chart libraries for data visualization
|
||||
- Share API for exporting workout data
|
||||
- Nostr integration for social sharing and sync
|
||||
- Calendar API for date handling
|
||||
- NDK for Nostr relay communication
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation & Enhanced History View
|
||||
1. Fix typo in WorkoutHistoryService filename
|
||||
2. Implement workout detail view navigation
|
||||
3. Add filtering and sorting to history list
|
||||
4. Enhance WorkoutCard with more metrics
|
||||
5. Implement search functionality
|
||||
6. Add basic Nostr status indicators (local vs. published)
|
||||
7. Improve calendar visualization with heatmap
|
||||
8. Add day summary popups to calendar
|
||||
|
||||
### Phase 2: Nostr Integration (MVP)
|
||||
1. Update database schema to track Nostr event IDs and publication status
|
||||
2. Implement visual indicators for workout source/status
|
||||
3. Add basic publishing functionality for local workouts
|
||||
4. Add filtering by source (local/published)
|
||||
|
||||
### Phase 3: Export & Sharing
|
||||
1. Add workout export functionality
|
||||
2. Implement social sharing features
|
||||
3. Create printable workout reports
|
||||
4. Add data backup options
|
||||
|
||||
### Phase 4: Integration with Profile Analytics
|
||||
1. Implement data sharing with Profile tab analytics
|
||||
2. Add navigation links to relevant analytics from workout details
|
||||
3. Ensure consistent data representation between History and Profile tabs
|
||||
|
||||
### Future Phases: Advanced Nostr Integration
|
||||
1. Create NostrWorkoutHistoryService for fetching Nostr-only workouts
|
||||
2. Implement workout importing functionality
|
||||
3. Add background sync for Nostr workouts
|
||||
4. Implement batch operations for publishing/importing
|
||||
5. Add cross-device synchronization
|
||||
|
||||
## UI Mockups
|
||||
|
||||
### History Tab with Source Indicators
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ HISTORY │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ MARCH 2025 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Push Day 1 🔄 > │ │ │
|
||||
│ │ │ Friday, Mar 7 │ │ │
|
||||
│ │ │ ⏱️ 1h 47m ⚖️ 9239 lb 🔄 120 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Bench Press │ │ │
|
||||
│ │ │ Incline Dumbbell Press │ │ │
|
||||
│ │ │ Tricep Pushdown │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Pull Day 1 📱 > │ │ │
|
||||
│ │ │ Wednesday, Mar 5 │ │ │
|
||||
│ │ │ ⏱️ 1h 36m ⚖️ 1396 lb 🔄 95 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Lat Pulldown │ │ │
|
||||
│ │ │ Seated Row │ │ │
|
||||
│ │ │ Bicep Curl │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ FEBRUARY 2025 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Leg Day 1 ☁️ > │ │ │
|
||||
│ │ │ Monday, Feb 28 │ │ │
|
||||
│ │ │ ⏱️ 1h 15m ⚖️ 8750 lb 🔄 87 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Squat │ │ │
|
||||
│ │ │ Leg Press │ │ │
|
||||
│ │ │ Leg Extension │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Legend:
|
||||
🔄 - Local only
|
||||
📱 - Published to Nostr
|
||||
☁️ - From Nostr (not stored locally)
|
||||
```
|
||||
|
||||
### Workout Detail View with Sharing
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ < WORKOUT DETAILS │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Push Day 1 │
|
||||
│ Friday, March 7, 2025 │
|
||||
│ │
|
||||
│ ⏱️ 1h 47m ⚖️ 9239 lb 🔄 120 reps │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ EXERCISES │ │
|
||||
│ │ │ │
|
||||
│ │ Bench Press │ │
|
||||
│ │ 135 lb × 12 │ │
|
||||
│ │ 185 lb × 10 │ │
|
||||
│ │ 205 lb × 8 │ │
|
||||
│ │ 225 lb × 6 │ │
|
||||
│ │ │ │
|
||||
│ │ Incline Dumbbell Press │ │
|
||||
│ │ 50 lb × 12 │ │
|
||||
│ │ 60 lb × 10 │ │
|
||||
│ │ 70 lb × 8 │ │
|
||||
│ │ │ │
|
||||
│ │ Tricep Pushdown │ │
|
||||
│ │ 50 lb × 15 │ │
|
||||
│ │ 60 lb × 12 │ │
|
||||
│ │ 70 lb × 10 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ NOTES │ │
|
||||
│ │ Felt strong today. Increased bench │ │
|
||||
│ │ press weight by 10 lbs. │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🔄 Local Only │ │
|
||||
│ │ │ │
|
||||
│ │ [Publish to Nostr] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Export Workout] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Note: Analytics Dashboard and Progress Tracking features have been moved to the Profile tab. See the Profile Tab Enhancement Design Document for details on these features.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Service method tests for data processing
|
||||
- Component rendering tests
|
||||
- Filter and search algorithm tests
|
||||
- Chart data preparation tests
|
||||
- Nostr event creation and parsing tests
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end workout flow tests
|
||||
- Database read/write performance tests
|
||||
- UI interaction tests
|
||||
- Cross-tab navigation tests
|
||||
- Nostr publishing and retrieval tests
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- AI-powered workout insights and recommendations
|
||||
- Advanced periodization analysis
|
||||
- Integration with wearable devices for additional metrics
|
||||
- Video playback of recorded exercises
|
||||
- Community benchmarking and anonymous comparisons
|
||||
- Full cross-device synchronization via Nostr
|
||||
- Collaborative workouts with friends
|
||||
|
||||
### Known Limitations
|
||||
- Performance may degrade with very large workout histories
|
||||
- Complex analytics require significant processing
|
||||
- Limited by available device storage
|
||||
- Some features require online connectivity
|
||||
- Nostr relay availability affects sync reliability
|
||||
|
||||
## Integration with Profile Tab
|
||||
|
||||
The History tab will integrate with the Profile tab's analytics and progress tracking features to provide a cohesive user experience:
|
||||
|
||||
### Data Flow
|
||||
- The same underlying workout data will power both History views and Profile analytics
|
||||
- The analytics service will process workout data for both tabs
|
||||
- Changes in one area will be reflected in the other
|
||||
|
||||
### Navigation Integration
|
||||
- Workout detail views will include links to relevant analytics in the Profile tab
|
||||
- The Profile tab's progress tracking will link back to relevant historical workouts
|
||||
- Consistent data visualization will be used across both tabs
|
||||
|
||||
### User Experience
|
||||
- Users will use the History tab for finding and reviewing specific workouts
|
||||
- Users will use the Profile tab for analyzing trends and tracking progress
|
||||
- The separation provides clearer context for each activity while maintaining data consistency
|
||||
|
||||
## References
|
||||
- [Workout Data Flow Specification](../WorkoutTab/WorkoutDataFlowSpec.md)
|
||||
- [Nostr Exercise NIP](../nostr-exercise-nip.md)
|
||||
- [WorkoutHistoryService implementation](../../../lib/db/services/WorkoutHistoryService.ts)
|
||||
- [NDK documentation](https://github.com/nostr-dev-kit/ndk)
|
||||
- [Profile Tab Enhancement Design Document](../ProfileTab/ProfileTabEnhancementDesignDoc.md)
|
49
lib/hooks/useAnalytics.ts
Normal file
49
lib/hooks/useAnalytics.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// lib/hooks/useAnalytics.ts
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { analyticsService } from '@/lib/services/AnalyticsService';
|
||||
import { useWorkoutService } from '@/components/DatabaseProvider';
|
||||
|
||||
/**
|
||||
* Hook to provide access to the analytics service
|
||||
* This hook ensures the analytics service is properly initialized with
|
||||
* the necessary database services
|
||||
*/
|
||||
export function useAnalytics() {
|
||||
const workoutService = useWorkoutService();
|
||||
|
||||
// Initialize the analytics service with the necessary services
|
||||
useEffect(() => {
|
||||
analyticsService.setWorkoutService(workoutService);
|
||||
|
||||
// We could also set the NostrWorkoutService here if needed
|
||||
// analyticsService.setNostrWorkoutService(nostrWorkoutService);
|
||||
|
||||
return () => {
|
||||
// Clear the cache when the component unmounts
|
||||
analyticsService.invalidateCache();
|
||||
};
|
||||
}, [workoutService]);
|
||||
|
||||
// Create a memoized object with the analytics methods
|
||||
const analytics = useMemo(() => ({
|
||||
// Workout statistics
|
||||
getWorkoutStats: (period: 'week' | 'month' | 'year' | 'all') =>
|
||||
analyticsService.getWorkoutStats(period),
|
||||
|
||||
// Exercise progress
|
||||
getExerciseProgress: (
|
||||
exerciseId: string,
|
||||
metric: 'weight' | 'reps' | 'volume',
|
||||
period: 'month' | 'year' | 'all'
|
||||
) => analyticsService.getExerciseProgress(exerciseId, metric, period),
|
||||
|
||||
// Personal records
|
||||
getPersonalRecords: (exerciseIds?: string[], limit?: number) =>
|
||||
analyticsService.getPersonalRecords(exerciseIds, limit),
|
||||
|
||||
// Cache management
|
||||
invalidateCache: () => analyticsService.invalidateCache()
|
||||
}), []);
|
||||
|
||||
return analytics;
|
||||
}
|
@ -285,6 +285,61 @@ export function usePOWRFeed(options: FeedOptions = {}) {
|
||||
* Hook for the Global tab in the social feed
|
||||
* Shows all workout-related content
|
||||
*/
|
||||
/**
|
||||
* Hook for the user's own activity feed
|
||||
* Shows only the user's own posts and workouts
|
||||
*/
|
||||
export function useUserActivityFeed(options: FeedOptions = {}) {
|
||||
const { currentUser } = useNDKCurrentUser();
|
||||
const { ndk } = useNDK();
|
||||
|
||||
// Create filters for user's own content
|
||||
const userFilters = useMemo<NDKFilter[]>(() => {
|
||||
if (!currentUser?.pubkey) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
kinds: [1] as any[], // Social posts
|
||||
authors: [currentUser.pubkey],
|
||||
limit: 30
|
||||
},
|
||||
{
|
||||
kinds: [30023] as any[], // Articles
|
||||
authors: [currentUser.pubkey],
|
||||
limit: 20
|
||||
},
|
||||
{
|
||||
kinds: [1301, 33401, 33402] as any[], // Workout-specific content
|
||||
authors: [currentUser.pubkey],
|
||||
limit: 30
|
||||
}
|
||||
];
|
||||
}, [currentUser?.pubkey]);
|
||||
|
||||
// Use feed events hook
|
||||
const feed = useFeedEvents(
|
||||
currentUser?.pubkey ? userFilters : false,
|
||||
{
|
||||
subId: 'user-activity-feed',
|
||||
feedType: 'user-activity',
|
||||
...options
|
||||
}
|
||||
);
|
||||
|
||||
// Feed monitor for auto-refresh
|
||||
const monitor = useFeedMonitor({
|
||||
onRefresh: async () => {
|
||||
return feed.resetFeed();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...feed,
|
||||
...monitor,
|
||||
hasContent: feed.entries.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
export function useGlobalFeed(options: FeedOptions = {}) {
|
||||
// Global filters - focus on workout content
|
||||
const globalFilters = useMemo<NDKFilter[]>(() => [
|
||||
@ -324,4 +379,4 @@ export function useGlobalFeed(options: FeedOptions = {}) {
|
||||
...feed,
|
||||
...monitor
|
||||
};
|
||||
}
|
||||
}
|
||||
|
313
lib/services/AnalyticsService.ts
Normal file
313
lib/services/AnalyticsService.ts
Normal file
@ -0,0 +1,313 @@
|
||||
// lib/services/AnalyticsService.ts
|
||||
import { Workout } from '@/types/workout';
|
||||
import { WorkoutService } from '@/lib/db/services/WorkoutService';
|
||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||
|
||||
/**
|
||||
* Workout statistics data structure
|
||||
*/
|
||||
export interface WorkoutStats {
|
||||
workoutCount: number;
|
||||
totalDuration: number; // in milliseconds
|
||||
totalVolume: number;
|
||||
averageIntensity: number;
|
||||
exerciseDistribution: Record<string, number>;
|
||||
frequencyByDay: number[]; // 0 = Sunday, 6 = Saturday
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress point for tracking exercise progress
|
||||
*/
|
||||
export interface ProgressPoint {
|
||||
date: number; // timestamp
|
||||
value: number;
|
||||
workoutId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal record data structure
|
||||
*/
|
||||
export interface PersonalRecord {
|
||||
id: string;
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
reps: number;
|
||||
date: number; // timestamp
|
||||
workoutId: string;
|
||||
previousRecord?: {
|
||||
value: number;
|
||||
date: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for calculating workout analytics and progress data
|
||||
*/
|
||||
export class AnalyticsService {
|
||||
private workoutService: WorkoutService | null = null;
|
||||
private nostrWorkoutService: NostrWorkoutService | null = null;
|
||||
private cache = new Map<string, any>();
|
||||
|
||||
// Set the workout service (called from React components)
|
||||
setWorkoutService(service: WorkoutService): void {
|
||||
this.workoutService = service;
|
||||
}
|
||||
|
||||
// Set the Nostr workout service (called from React components)
|
||||
setNostrWorkoutService(service: NostrWorkoutService): void {
|
||||
this.nostrWorkoutService = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout statistics for a given period
|
||||
*/
|
||||
async getWorkoutStats(period: 'week' | 'month' | 'year' | 'all'): Promise<WorkoutStats> {
|
||||
const cacheKey = `stats-${period}`;
|
||||
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
|
||||
|
||||
// Get workouts for the period
|
||||
const workouts = await this.getWorkoutsForPeriod(period);
|
||||
|
||||
// Calculate statistics
|
||||
const stats: WorkoutStats = {
|
||||
workoutCount: workouts.length,
|
||||
totalDuration: 0,
|
||||
totalVolume: 0,
|
||||
averageIntensity: 0,
|
||||
exerciseDistribution: {},
|
||||
frequencyByDay: [0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
|
||||
// Process workouts
|
||||
workouts.forEach(workout => {
|
||||
// Add duration
|
||||
stats.totalDuration += (workout.endTime || Date.now()) - workout.startTime;
|
||||
|
||||
// Add volume
|
||||
stats.totalVolume += workout.totalVolume || 0;
|
||||
|
||||
// Track frequency by day
|
||||
const day = new Date(workout.startTime).getDay();
|
||||
stats.frequencyByDay[day]++;
|
||||
|
||||
// Track exercise distribution
|
||||
workout.exercises?.forEach(exercise => {
|
||||
const exerciseId = exercise.id;
|
||||
stats.exerciseDistribution[exerciseId] = (stats.exerciseDistribution[exerciseId] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate average intensity
|
||||
stats.averageIntensity = workouts.length > 0
|
||||
? workouts.reduce((sum, workout) => sum + (workout.averageRpe || 0), 0) / workouts.length
|
||||
: 0;
|
||||
|
||||
this.cache.set(cacheKey, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific exercise
|
||||
*/
|
||||
async getExerciseProgress(
|
||||
exerciseId: string,
|
||||
metric: 'weight' | 'reps' | 'volume',
|
||||
period: 'month' | 'year' | 'all'
|
||||
): Promise<ProgressPoint[]> {
|
||||
const cacheKey = `progress-${exerciseId}-${metric}-${period}`;
|
||||
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
|
||||
|
||||
// Get workouts for the period
|
||||
const workouts = await this.getWorkoutsForPeriod(period);
|
||||
|
||||
// Filter workouts that contain the exercise
|
||||
const relevantWorkouts = workouts.filter(workout =>
|
||||
workout.exercises?.some(exercise =>
|
||||
exercise.id === exerciseId || exercise.exerciseId === exerciseId
|
||||
)
|
||||
);
|
||||
|
||||
// Extract progress points
|
||||
const progressPoints: ProgressPoint[] = [];
|
||||
|
||||
relevantWorkouts.forEach(workout => {
|
||||
const exercise = workout.exercises?.find(e =>
|
||||
e.id === exerciseId || e.exerciseId === exerciseId
|
||||
);
|
||||
|
||||
if (!exercise) return;
|
||||
|
||||
let value = 0;
|
||||
|
||||
switch (metric) {
|
||||
case 'weight':
|
||||
// Find the maximum weight used in any set
|
||||
value = Math.max(...exercise.sets.map(set => set.weight || 0));
|
||||
break;
|
||||
case 'reps':
|
||||
// Find the maximum reps in any set
|
||||
value = Math.max(...exercise.sets.map(set => set.reps || 0));
|
||||
break;
|
||||
case 'volume':
|
||||
// Calculate total volume (weight * reps) for the exercise
|
||||
value = exercise.sets.reduce((sum, set) =>
|
||||
sum + ((set.weight || 0) * (set.reps || 0)), 0);
|
||||
break;
|
||||
}
|
||||
|
||||
progressPoints.push({
|
||||
date: workout.startTime,
|
||||
value,
|
||||
workoutId: workout.id
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by date
|
||||
progressPoints.sort((a, b) => a.date - b.date);
|
||||
|
||||
this.cache.set(cacheKey, progressPoints);
|
||||
return progressPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personal records for exercises
|
||||
*/
|
||||
async getPersonalRecords(
|
||||
exerciseIds?: string[],
|
||||
limit?: number
|
||||
): Promise<PersonalRecord[]> {
|
||||
const cacheKey = `records-${exerciseIds?.join('-') || 'all'}-${limit || 'all'}`;
|
||||
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
|
||||
|
||||
// Get all workouts
|
||||
const workouts = await this.getWorkoutsForPeriod('all');
|
||||
|
||||
// Track personal records by exercise
|
||||
const recordsByExercise = new Map<string, PersonalRecord>();
|
||||
const previousRecords = new Map<string, { value: number; date: number }>();
|
||||
|
||||
// Process workouts in chronological order
|
||||
workouts.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
workouts.forEach(workout => {
|
||||
workout.exercises?.forEach(exercise => {
|
||||
// Skip if we're filtering by exerciseIds and this one isn't included
|
||||
if (exerciseIds && !exerciseIds.includes(exercise.id) &&
|
||||
!exerciseIds.includes(exercise.exerciseId || '')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the maximum weight used in any set
|
||||
const maxWeightSet = exercise.sets.reduce((max, set) => {
|
||||
if (!set.weight) return max;
|
||||
if (!max || set.weight > max.weight) {
|
||||
return { weight: set.weight, reps: set.reps || 0 };
|
||||
}
|
||||
return max;
|
||||
}, null as { weight: number; reps: number } | null);
|
||||
|
||||
if (!maxWeightSet) return;
|
||||
|
||||
const exerciseId = exercise.exerciseId || exercise.id;
|
||||
const currentRecord = recordsByExercise.get(exerciseId);
|
||||
|
||||
// Check if this is a new record
|
||||
if (!currentRecord || maxWeightSet.weight > currentRecord.value) {
|
||||
// Save the previous record
|
||||
if (currentRecord) {
|
||||
previousRecords.set(exerciseId, {
|
||||
value: currentRecord.value,
|
||||
date: currentRecord.date
|
||||
});
|
||||
}
|
||||
|
||||
// Create new record
|
||||
recordsByExercise.set(exerciseId, {
|
||||
id: `pr-${exerciseId}-${workout.id}`,
|
||||
exerciseId,
|
||||
exerciseName: exercise.title,
|
||||
value: maxWeightSet.weight,
|
||||
unit: 'lb',
|
||||
reps: maxWeightSet.reps,
|
||||
date: workout.startTime,
|
||||
workoutId: workout.id,
|
||||
previousRecord: previousRecords.get(exerciseId)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by date (most recent first)
|
||||
let records = Array.from(recordsByExercise.values())
|
||||
.sort((a, b) => b.date - a.date);
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit) {
|
||||
records = records.slice(0, limit);
|
||||
}
|
||||
|
||||
this.cache.set(cacheKey, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get workouts for a period
|
||||
*/
|
||||
private async getWorkoutsForPeriod(period: 'week' | 'month' | 'year' | 'all'): Promise<Workout[]> {
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (period) {
|
||||
case 'week':
|
||||
startDate = new Date(now);
|
||||
startDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(now);
|
||||
startDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'year':
|
||||
startDate = new Date(now);
|
||||
startDate.setFullYear(now.getFullYear() - 1);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
startDate = new Date(0); // Beginning of time
|
||||
break;
|
||||
}
|
||||
|
||||
// Get workouts from both local and Nostr sources
|
||||
let localWorkouts: Workout[] = [];
|
||||
if (this.workoutService) {
|
||||
localWorkouts = await this.workoutService.getWorkoutsByDateRange(startDate.getTime(), now.getTime());
|
||||
}
|
||||
|
||||
// In a real implementation, we would also fetch Nostr workouts
|
||||
// const nostrWorkouts = await this.nostrWorkoutService?.getWorkoutsByDateRange(startDate.getTime(), now.getTime());
|
||||
const nostrWorkouts: Workout[] = [];
|
||||
|
||||
// Combine and deduplicate workouts
|
||||
const allWorkouts = [...localWorkouts];
|
||||
|
||||
// Add Nostr workouts that aren't already in local workouts
|
||||
for (const nostrWorkout of nostrWorkouts) {
|
||||
if (!allWorkouts.some(w => w.id === nostrWorkout.id)) {
|
||||
allWorkouts.push(nostrWorkout);
|
||||
}
|
||||
}
|
||||
|
||||
return allWorkouts.sort((a, b) => b.startTime - a.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache when new workouts are added
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const analyticsService = new AnalyticsService();
|
@ -61,7 +61,7 @@ export type UpdateEntryFn = (id: string, updater: (entry: AnyFeedEntry) => AnyFe
|
||||
|
||||
// Feed filter options
|
||||
export interface FeedFilterOptions {
|
||||
feedType: 'following' | 'powr' | 'global';
|
||||
feedType: 'following' | 'powr' | 'global' | 'user-activity';
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
@ -78,5 +78,5 @@ export interface FeedOptions {
|
||||
enabled?: boolean;
|
||||
filterFn?: FeedEntryFilterFn;
|
||||
sortFn?: (a: AnyFeedEntry, b: AnyFeedEntry) => number;
|
||||
feedType?: 'following' | 'powr' | 'global'; // Added this property
|
||||
}
|
||||
feedType?: 'following' | 'powr' | 'global' | 'user-activity'; // Added this property
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user