mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 10:51:19 +00:00
history tab wip, still need to connect db
This commit is contained in:
parent
3feb366a78
commit
001cb3078d
@ -1,32 +0,0 @@
|
||||
// app/(tabs)/social.tsx
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bell } from 'lucide-react-native';
|
||||
import Header from '@/components/Header';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
|
||||
export default function SocialScreen() {
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
useLogo={true}
|
||||
rightElement={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => console.log('Open notifications')}
|
||||
>
|
||||
<View className="relative">
|
||||
<Bell className="text-foreground" />
|
||||
<View className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
</View>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Social Screen</Text>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
56
app/(tabs)/history/_layout.tsx
Normal file
56
app/(tabs)/history/_layout.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
// app/(tabs)/history/_layout.tsx
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import HistoryScreen from '@/app/(tabs)/history/workoutHistory';
|
||||
import CalendarScreen from '@/app/(tabs)/history/calendar';
|
||||
import Header from '@/components/Header';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import type { CustomTheme } from '@/lib/theme';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
|
||||
const Tab = createMaterialTopTabNavigator();
|
||||
|
||||
export default function HistoryLayout() {
|
||||
const theme = useTheme() as CustomTheme;
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header title="History" useLogo={true} />
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="history"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'none', // Match the social tab (was '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="history"
|
||||
component={HistoryScreen}
|
||||
options={{ title: 'History' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="calendar"
|
||||
component={CalendarScreen}
|
||||
options={{ title: 'Calendar' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
381
app/(tabs)/history/calendar.tsx
Normal file
381
app/(tabs)/history/calendar.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
// app/(tabs)/history/calendar.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator, TouchableOpacity, Pressable, RefreshControl } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
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 WorkoutCard from '@/components/workout/WorkoutCard';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react-native';
|
||||
|
||||
// Add custom styles for 1/7 width (for calendar days)
|
||||
const styles = {
|
||||
calendarDay: "w-[14.28%]"
|
||||
};
|
||||
|
||||
// Week days for the calendar view - Fixed the duplicate key issue
|
||||
const WEEK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// Mock data for when database tables aren't yet created
|
||||
const mockWorkouts: Workout[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Push 1',
|
||||
type: 'strength',
|
||||
exercises: [],
|
||||
startTime: new Date('2025-03-07T10:00:00').getTime(),
|
||||
endTime: new Date('2025-03-07T11:47:00').getTime(),
|
||||
isCompleted: true,
|
||||
created_at: new Date('2025-03-07T10:00:00').getTime(),
|
||||
availability: { source: ['local'] },
|
||||
totalVolume: 9239
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Pull 1',
|
||||
type: 'strength',
|
||||
exercises: [],
|
||||
startTime: new Date('2025-03-05T14:00:00').getTime(),
|
||||
endTime: new Date('2025-03-05T15:36:00').getTime(),
|
||||
isCompleted: true,
|
||||
created_at: new Date('2025-03-05T14:00:00').getTime(),
|
||||
availability: { source: ['local'] },
|
||||
totalVolume: 1396
|
||||
}
|
||||
];
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<Date>(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [workoutDates, setWorkoutDates] = useState<Set<string>>(new Set());
|
||||
const [useMockData, setUseMockData] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Initialize workout history service
|
||||
const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]);
|
||||
|
||||
// Load workouts function
|
||||
const loadWorkouts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const allWorkouts = await workoutHistoryService.getAllWorkouts();
|
||||
setWorkouts(allWorkouts);
|
||||
setUseMockData(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading workouts:', error);
|
||||
|
||||
// Check if the error is about missing tables
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (errorMsg.includes('no such table')) {
|
||||
console.log('Using mock data because workout tables not yet created');
|
||||
setWorkouts(mockWorkouts);
|
||||
setUseMockData(true);
|
||||
} else {
|
||||
// For other errors, just show empty state
|
||||
setWorkouts([]);
|
||||
setUseMockData(false);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load workouts
|
||||
useEffect(() => {
|
||||
loadWorkouts();
|
||||
}, [workoutHistoryService]);
|
||||
|
||||
// Pull to refresh handler
|
||||
const onRefresh = React.useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadWorkouts();
|
||||
}, []);
|
||||
|
||||
// Load workout dates for selected month
|
||||
useEffect(() => {
|
||||
const getWorkoutDatesForMonth = async () => {
|
||||
try {
|
||||
const year = selectedMonth.getFullYear();
|
||||
const month = selectedMonth.getMonth();
|
||||
|
||||
let dates: Date[] = [];
|
||||
|
||||
// If we're using mock data, filter from mock workouts
|
||||
if (useMockData) {
|
||||
dates = workouts
|
||||
.filter(workout => {
|
||||
const date = new Date(workout.startTime);
|
||||
return date.getFullYear() === year && date.getMonth() === month;
|
||||
})
|
||||
.map(workout => new Date(workout.startTime));
|
||||
} else {
|
||||
// Try to use the service method if it exists and table exists
|
||||
try {
|
||||
if (typeof workoutHistoryService.getWorkoutDatesInMonth === 'function') {
|
||||
dates = await workoutHistoryService.getWorkoutDatesInMonth(year, month);
|
||||
} else {
|
||||
// Otherwise filter from loaded workouts
|
||||
dates = workouts
|
||||
.filter(workout => {
|
||||
const date = new Date(workout.startTime);
|
||||
return date.getFullYear() === year && date.getMonth() === month;
|
||||
})
|
||||
.map(workout => new Date(workout.startTime));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting workout dates:', error);
|
||||
// If table doesn't exist, use empty array
|
||||
dates = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to strings for the Set
|
||||
const dateStrings = dates.map(date => format(date, 'yyyy-MM-dd'));
|
||||
setWorkoutDates(new Set(dateStrings));
|
||||
} catch (error) {
|
||||
console.error('Error getting workout dates:', error);
|
||||
setWorkoutDates(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
getWorkoutDatesForMonth();
|
||||
}, [selectedMonth, workouts, workoutHistoryService, useMockData]);
|
||||
|
||||
// Get dates for current month's calendar
|
||||
const getDaysInMonth = (year: number, month: number) => {
|
||||
const date = new Date(year, month, 1);
|
||||
const days: (Date | null)[] = [];
|
||||
let day = 1;
|
||||
|
||||
// Get the day of week for the first day (0 = Sunday, 1 = Monday, etc.)
|
||||
const firstDayOfWeek = date.getDay() === 0 ? 6 : date.getDay() - 1; // Convert to Monday-based
|
||||
|
||||
// Add empty days for the beginning of the month
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add all days in the month
|
||||
while (date.getMonth() === month) {
|
||||
days.push(new Date(year, month, day));
|
||||
day++;
|
||||
date.setDate(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const daysInMonth = getDaysInMonth(
|
||||
selectedMonth.getFullYear(),
|
||||
selectedMonth.getMonth()
|
||||
);
|
||||
|
||||
const goToPreviousMonth = () => {
|
||||
setSelectedMonth(prev => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setSelectedMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
// Check if a date has workouts
|
||||
const hasWorkout = (date: Date | null) => {
|
||||
if (!date) return false;
|
||||
return workoutDates.has(format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
// Handle date selection in calendar
|
||||
const handleDateSelect = (date: Date | null) => {
|
||||
if (!date) return;
|
||||
setSelectedDate(date);
|
||||
};
|
||||
|
||||
// Get workouts for selected date
|
||||
const [selectedDateWorkouts, setSelectedDateWorkouts] = useState<Workout[]>([]);
|
||||
const [loadingDateWorkouts, setLoadingDateWorkouts] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWorkoutsForDate = async () => {
|
||||
try {
|
||||
setLoadingDateWorkouts(true);
|
||||
|
||||
if (useMockData) {
|
||||
// Use mock data filtering
|
||||
const filtered = workouts.filter(workout =>
|
||||
isSameDay(new Date(workout.startTime), selectedDate)
|
||||
);
|
||||
setSelectedDateWorkouts(filtered);
|
||||
} else {
|
||||
try {
|
||||
if (typeof workoutHistoryService.getWorkoutsByDate === 'function') {
|
||||
// Use the service method if available
|
||||
const dateWorkouts = await workoutHistoryService.getWorkoutsByDate(selectedDate);
|
||||
setSelectedDateWorkouts(dateWorkouts);
|
||||
} else {
|
||||
// Fall back to filtering the already loaded workouts
|
||||
const filtered = workouts.filter(workout =>
|
||||
isSameDay(new Date(workout.startTime), selectedDate)
|
||||
);
|
||||
setSelectedDateWorkouts(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle the case where the workout table doesn't exist
|
||||
console.error('Error loading workouts for date:', error);
|
||||
setSelectedDateWorkouts([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading workouts for date:', error);
|
||||
setSelectedDateWorkouts([]);
|
||||
} finally {
|
||||
setLoadingDateWorkouts(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWorkoutsForDate();
|
||||
}, [selectedDate, workouts, workoutHistoryService, useMockData]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
<View className="p-4">
|
||||
{useMockData && (
|
||||
<View className="bg-primary/5 rounded-lg p-4 mb-4 border border-border">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
Showing example data. Your completed workouts will appear here.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Calendar section */}
|
||||
<View className="mb-6">
|
||||
<View className="flex-row items-center mb-4">
|
||||
<Calendar size={20} className="text-primary mr-2" />
|
||||
<Text className="text-lg font-semibold">Workout Calendar</Text>
|
||||
</View>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
{/* Month navigation */}
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousMonth}
|
||||
className="p-2 rounded-full bg-muted"
|
||||
>
|
||||
<ChevronLeft size={20} className="text-foreground" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-foreground text-lg font-semibold">
|
||||
{format(selectedMonth, 'MMMM yyyy')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={goToNextMonth}
|
||||
className="p-2 rounded-full bg-muted"
|
||||
>
|
||||
<ChevronRight size={20} className="text-foreground" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Week days header - Fixed with unique keys */}
|
||||
<View className="flex-row mb-2">
|
||||
{WEEK_DAYS.map(day => (
|
||||
<View key={day} className={styles.calendarDay}>
|
||||
<Text className="text-center text-muted-foreground font-medium">
|
||||
{day.charAt(0)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<View className="flex-row flex-wrap">
|
||||
{daysInMonth.map((date, index) => (
|
||||
<Pressable
|
||||
key={`day-${index}`}
|
||||
className={styles.calendarDay}
|
||||
onPress={() => date && handleDateSelect(date)}
|
||||
>
|
||||
<View className="aspect-square items-center justify-center">
|
||||
{date ? (
|
||||
<View
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full items-center justify-center",
|
||||
isSameDay(date, selectedDate) && !hasWorkout(date) && "bg-muted",
|
||||
hasWorkout(date) && "bg-primary",
|
||||
isSameDay(date, new Date()) && !hasWorkout(date) && !isSameDay(date, selectedDate) && "border border-primary"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
hasWorkout(date) && "text-primary-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-8 h-8" />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Selected date workouts */}
|
||||
<View className="mb-4">
|
||||
<Text className="text-foreground text-xl font-semibold mb-4">
|
||||
{format(selectedDate, 'MMMM d, yyyy')}
|
||||
</Text>
|
||||
|
||||
{isLoading || loadingDateWorkouts ? (
|
||||
<View className="items-center justify-center py-10">
|
||||
<ActivityIndicator size="large" className="mb-4" />
|
||||
<Text className="text-muted-foreground">Loading workouts...</Text>
|
||||
</View>
|
||||
) : selectedDateWorkouts.length === 0 ? (
|
||||
<View className="items-center justify-center py-10">
|
||||
<Text className="text-muted-foreground">No workouts on this date</Text>
|
||||
{!useMockData && (
|
||||
<Text className="text-muted-foreground mt-2 text-center">
|
||||
Complete a workout on this day to see it here
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{selectedDateWorkouts.map(workout => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
showDate={false}
|
||||
showExercises={true}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Add bottom padding for better scrolling experience */}
|
||||
<View className="h-20" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
160
app/(tabs)/history/workoutHistory.tsx
Normal file
160
app/(tabs)/history/workoutHistory.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
// app/(tabs)/history/workoutHistory.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
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 WorkoutCard from '@/components/workout/WorkoutCard';
|
||||
|
||||
// Mock data for when database tables aren't yet created
|
||||
const mockWorkouts: Workout[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Push 1',
|
||||
type: 'strength',
|
||||
exercises: [],
|
||||
startTime: new Date('2025-03-07T10:00:00').getTime(),
|
||||
endTime: new Date('2025-03-07T11:47:00').getTime(),
|
||||
isCompleted: true,
|
||||
created_at: new Date('2025-03-07T10:00:00').getTime(),
|
||||
availability: { source: ['local'] },
|
||||
totalVolume: 9239
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Pull 1',
|
||||
type: 'strength',
|
||||
exercises: [],
|
||||
startTime: new Date('2025-03-05T14:00:00').getTime(),
|
||||
endTime: new Date('2025-03-05T15:36:00').getTime(),
|
||||
isCompleted: true,
|
||||
created_at: new Date('2025-03-05T14:00:00').getTime(),
|
||||
availability: { source: ['local'] },
|
||||
totalVolume: 1396
|
||||
}
|
||||
];
|
||||
|
||||
// Group workouts by month
|
||||
const groupWorkoutsByMonth = (workouts: Workout[]) => {
|
||||
const grouped: Record<string, Workout[]> = {};
|
||||
|
||||
workouts.forEach(workout => {
|
||||
const monthKey = format(workout.startTime, 'MMMM yyyy');
|
||||
if (!grouped[monthKey]) {
|
||||
grouped[monthKey] = [];
|
||||
}
|
||||
grouped[monthKey].push(workout);
|
||||
});
|
||||
|
||||
return Object.entries(grouped);
|
||||
};
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [useMockData, setUseMockData] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Initialize workout history service
|
||||
const workoutHistoryService = React.useMemo(() => new WorkoutHistoryService(db), [db]);
|
||||
|
||||
// Load workouts
|
||||
const loadWorkouts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const allWorkouts = await workoutHistoryService.getAllWorkouts();
|
||||
setWorkouts(allWorkouts);
|
||||
setUseMockData(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading workouts:', error);
|
||||
|
||||
// Check if the error is about missing tables
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (errorMsg.includes('no such table')) {
|
||||
console.log('Using mock data because workout tables not yet created');
|
||||
setWorkouts(mockWorkouts);
|
||||
setUseMockData(true);
|
||||
} else {
|
||||
// For other errors, just show empty state
|
||||
setWorkouts([]);
|
||||
setUseMockData(false);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadWorkouts();
|
||||
}, [workoutHistoryService]);
|
||||
|
||||
// Pull to refresh handler
|
||||
const onRefresh = React.useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadWorkouts();
|
||||
}, []);
|
||||
|
||||
// Group workouts by month
|
||||
const groupedWorkouts = groupWorkoutsByMonth(workouts);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{isLoading && !refreshing ? (
|
||||
<View className="items-center justify-center py-20">
|
||||
<ActivityIndicator size="large" className="mb-4" />
|
||||
<Text className="text-muted-foreground">Loading workout history...</Text>
|
||||
</View>
|
||||
) : workouts.length === 0 ? (
|
||||
<View className="items-center justify-center py-20">
|
||||
<Text className="text-muted-foreground">No workouts recorded yet.</Text>
|
||||
<Text className="text-muted-foreground mt-2">
|
||||
Complete a workout to see it here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
// Display grouped workouts by month
|
||||
<View className="p-4">
|
||||
{useMockData && (
|
||||
<View className="bg-primary/5 rounded-lg p-4 mb-4 border border-border">
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
Showing example data. Your completed workouts will appear here.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{groupedWorkouts.map(([month, monthWorkouts]) => (
|
||||
<View key={month} className="mb-6">
|
||||
<Text className="text-foreground text-xl font-semibold mb-4">
|
||||
{month.toUpperCase()}
|
||||
</Text>
|
||||
|
||||
{monthWorkouts.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
showDate={true}
|
||||
showExercises={true}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Add bottom padding for better scrolling experience */}
|
||||
<View className="h-20" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
// app/(tabs)/index.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ScrollView, View } from 'react-native'
|
||||
import { ScrollView, View, TouchableOpacity } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { router } from 'expo-router'
|
||||
import {
|
||||
@ -20,7 +20,7 @@ import FavoriteTemplate from '@/components/workout/FavoriteTemplate'
|
||||
import { useWorkoutStore } from '@/stores/workoutStore'
|
||||
import { Text } from '@/components/ui/text'
|
||||
import { getRandomWorkoutTitle } from '@/utils/workoutTitles'
|
||||
import { Bell } from 'lucide-react-native';
|
||||
import { Bell, Star, Clock, Dumbbell } from 'lucide-react-native';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface FavoriteTemplateData {
|
||||
@ -44,6 +44,8 @@ type PendingWorkoutAction =
|
||||
| { type: 'quick-start' }
|
||||
| { type: 'template', templateId: string }
|
||||
| { type: 'template-select' };
|
||||
|
||||
const purpleColor = 'hsl(261, 90%, 66%)';
|
||||
|
||||
export default function WorkoutScreen() {
|
||||
const { startWorkout } = useWorkoutStore.getState();
|
||||
@ -204,6 +206,30 @@ export default function WorkoutScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format time ago
|
||||
const getTimeAgo = (timestamp: number) => {
|
||||
const now = Date.now();
|
||||
const diffInSeconds = Math.floor((now - timestamp) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} min ago`;
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours} hr ago`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays === 1) return 'Yesterday';
|
||||
if (diffInDays < 30) return `${diffInDays} days ago`;
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
if (diffInMonths === 1) return '1 month ago';
|
||||
if (diffInMonths < 12) return `${diffInMonths} months ago`;
|
||||
|
||||
return 'Over a year ago';
|
||||
};
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<Header
|
||||
@ -227,47 +253,136 @@ export default function WorkoutScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<HomeWorkout
|
||||
onStartBlank={handleQuickStart}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
/>
|
||||
|
||||
{/* Start a Workout section - without the outer box */}
|
||||
<View className="mb-6">
|
||||
<Text className="text-xl font-semibold mb-4">Start a Workout</Text>
|
||||
<Text className="text-sm text-muted-foreground mb-4">
|
||||
Begin a new workout or choose from one of your templates.
|
||||
</Text>
|
||||
|
||||
{/* Favorites section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Favorites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingFavorites ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Loading favorites...
|
||||
</Text>
|
||||
</View>
|
||||
) : favoriteWorkouts.length === 0 ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Star workouts from your library to see them here
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="gap-4">
|
||||
{favoriteWorkouts.map(template => (
|
||||
<FavoriteTemplate
|
||||
key={template.id}
|
||||
title={template.title}
|
||||
exercises={template.exercises}
|
||||
duration={template.duration}
|
||||
exerciseCount={template.exerciseCount}
|
||||
isFavorited={true}
|
||||
onPress={() => handleStartWorkout(template.id)}
|
||||
onFavoritePress={() => handleFavoritePress(template.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Buttons from HomeWorkout but directly here */}
|
||||
<View className="gap-4">
|
||||
<Button
|
||||
variant="default" // This ensures it uses the primary purple color
|
||||
className="w-full"
|
||||
onPress={handleQuickStart}
|
||||
style={{ backgroundColor: purpleColor }}
|
||||
>
|
||||
<Text className="text-white font-medium">Quick Start</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={handleSelectTemplate}
|
||||
>
|
||||
<Text>Use Template</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Favorites section with adjusted grid layout */}
|
||||
<View className="mt-6">
|
||||
<Text className="text-xl font-semibold mb-4">Favorites</Text>
|
||||
|
||||
{isLoadingFavorites ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Loading favorites...
|
||||
</Text>
|
||||
</View>
|
||||
) : favoriteWorkouts.length === 0 ? (
|
||||
<View className="p-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Star workouts from your library to see them here
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row flex-wrap justify-between">
|
||||
{favoriteWorkouts.map(template => (
|
||||
<TouchableOpacity
|
||||
key={template.id}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleStartWorkout(template.id)}
|
||||
className="w-[48%] mb-3"
|
||||
style={{ aspectRatio: 1 / 0.85 }} // Slightly less tall than a square
|
||||
>
|
||||
<Card className="border border-border h-full">
|
||||
<CardContent className="p-4 flex-1 justify-between">
|
||||
{/* Top section with title and star */}
|
||||
<View className="flex-1">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<Text className="text-base font-medium flex-1 mr-1" numberOfLines={2}>
|
||||
{template.title}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFavoritePress(template.id);
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={16}
|
||||
className="text-primary"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* First 2 exercises */}
|
||||
<View className="mt-2">
|
||||
{template.exercises.slice(0, 2).map((exercise, index) => (
|
||||
<Text key={index} className="text-xs text-foreground" numberOfLines={1}>
|
||||
• {exercise.title}
|
||||
</Text>
|
||||
))}
|
||||
{template.exercises.length > 2 && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
+{template.exercises.length - 2} more
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats row */}
|
||||
<View className="mt-2 pt-2 border-t border-border">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
<Dumbbell size={14} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
{template.exerciseCount}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{template.duration && (
|
||||
<View className="flex-row items-center">
|
||||
<Clock size={14} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
{Math.round(template.duration / 60)} min
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Last performed info */}
|
||||
{template.lastUsed && (
|
||||
<View className="flex-row items-center mt-2">
|
||||
<Clock size={14} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Last: {getTimeAgo(template.lastUsed)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<AlertDialog open={showActiveWorkoutModal}>
|
||||
|
116
components/workout/WorkoutCard.tsx
Normal file
116
components/workout/WorkoutCard.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
// components/workout/WorkoutCard.tsx
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { ChevronRight } from 'lucide-react-native';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { format } from 'date-fns';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface WorkoutCardProps {
|
||||
workout: Workout;
|
||||
showDate?: boolean;
|
||||
showExercises?: boolean;
|
||||
}
|
||||
|
||||
// Calculate duration in hours and minutes
|
||||
const formatDuration = (startTime: number, endTime: number) => {
|
||||
const durationMs = endTime - startTime;
|
||||
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const WorkoutCard: React.FC<WorkoutCardProps> = ({
|
||||
workout,
|
||||
showDate = true,
|
||||
showExercises = true
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handlePress = () => {
|
||||
// Navigate to workout details
|
||||
console.log(`Navigate to workout ${workout.id}`);
|
||||
// Implement navigation when endpoint is available
|
||||
// router.push(`/workout/${workout.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-center mb-2">
|
||||
<Text className="text-foreground text-lg font-semibold">{workout.title}</Text>
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<ChevronRight className="text-muted-foreground" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{showDate && (
|
||||
<Text className="text-muted-foreground mb-2">
|
||||
{format(workout.startTime, 'EEEE, MMM d')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className="flex-row items-center mt-2">
|
||||
<View className="flex-row items-center mr-4">
|
||||
<View className="w-6 h-6 items-center justify-center mr-1">
|
||||
<Text className="text-muted-foreground">⏱️</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground">
|
||||
{formatDuration(workout.startTime, workout.endTime || Date.now())}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center mr-4">
|
||||
<View className="w-6 h-6 items-center justify-center mr-1">
|
||||
<Text className="text-muted-foreground">⚖️</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground">
|
||||
{workout.totalVolume ? `${workout.totalVolume} lb` : '0 lb'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{workout.totalReps && (
|
||||
<View className="flex-row items-center">
|
||||
<View className="w-6 h-6 items-center justify-center mr-1">
|
||||
<Text className="text-muted-foreground">🔄</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground">
|
||||
{workout.totalReps} reps
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show exercises if requested */}
|
||||
{showExercises && (
|
||||
<View className="mt-4">
|
||||
<Text className="text-foreground font-semibold mb-1">Exercise</Text>
|
||||
{/* In a real implementation, you would map through actual exercises */}
|
||||
{workout.exercises && workout.exercises.length > 0 ? (
|
||||
workout.exercises.slice(0, 3).map((exercise, idx) => (
|
||||
<Text key={idx} className="text-foreground mb-1">
|
||||
{exercise.title}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text className="text-muted-foreground">No exercises recorded</Text>
|
||||
)}
|
||||
|
||||
{workout.exercises && workout.exercises.length > 3 && (
|
||||
<Text className="text-muted-foreground">
|
||||
+{workout.exercises.length - 3} more exercises
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutCard;
|
198
lib/db/services/WorkoutHIstoryService.ts
Normal file
198
lib/db/services/WorkoutHIstoryService.ts
Normal file
@ -0,0 +1,198 @@
|
||||
// lib/db/services/WorkoutHistoryService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { format } from 'date-fns';
|
||||
import { DbService } from '../db-service';
|
||||
|
||||
export class WorkoutHistoryService {
|
||||
private db: DbService;
|
||||
|
||||
constructor(database: SQLiteDatabase) {
|
||||
this.db = new DbService(database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workouts, sorted by date in descending order
|
||||
*/
|
||||
async getAllWorkouts(): Promise<Workout[]> {
|
||||
try {
|
||||
const workouts = await this.db.getAllAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
}>(
|
||||
`SELECT * FROM workouts
|
||||
ORDER BY start_time DESC`
|
||||
);
|
||||
|
||||
// Transform database records to Workout objects
|
||||
return workouts.map(row => this.mapRowToWorkout(row));
|
||||
} catch (error) {
|
||||
console.error('Error getting workouts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workouts for a specific date
|
||||
*/
|
||||
async getWorkoutsByDate(date: Date): Promise<Workout[]> {
|
||||
try {
|
||||
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const endOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999).getTime();
|
||||
|
||||
const workouts = await this.db.getAllAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
}>(
|
||||
`SELECT * FROM workouts
|
||||
WHERE start_time >= ? AND start_time <= ?
|
||||
ORDER BY start_time DESC`,
|
||||
[startOfDay, endOfDay]
|
||||
);
|
||||
|
||||
return workouts.map(row => this.mapRowToWorkout(row));
|
||||
} catch (error) {
|
||||
console.error('Error getting workouts by date:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dates that have workouts within a month
|
||||
*/
|
||||
async getWorkoutDatesInMonth(year: number, month: number): Promise<Date[]> {
|
||||
try {
|
||||
const startOfMonth = new Date(year, month, 1).getTime();
|
||||
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59, 999).getTime();
|
||||
|
||||
const result = await this.db.getAllAsync<{
|
||||
start_time: number;
|
||||
}>(
|
||||
`SELECT DISTINCT start_time FROM workouts
|
||||
WHERE start_time >= ? AND start_time <= ?`,
|
||||
[startOfMonth, endOfMonth]
|
||||
);
|
||||
|
||||
return result.map(row => new Date(row.start_time));
|
||||
} catch (error) {
|
||||
console.error('Error getting workout dates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout details including exercises
|
||||
*/
|
||||
async getWorkoutDetails(workoutId: string): Promise<Workout | null> {
|
||||
try {
|
||||
const workout = await this.db.getFirstAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
}>(
|
||||
`SELECT * FROM workouts WHERE id = ?`,
|
||||
[workoutId]
|
||||
);
|
||||
|
||||
if (!workout) return null;
|
||||
|
||||
// Get exercises for this workout
|
||||
// This is just a skeleton - you'll need to implement the actual query
|
||||
// based on your database schema
|
||||
const exercises = await this.db.getAllAsync(
|
||||
`SELECT * FROM workout_exercises WHERE workout_id = ?`,
|
||||
[workoutId]
|
||||
);
|
||||
|
||||
const workoutObj = this.mapRowToWorkout(workout);
|
||||
// You would set the exercises property here based on your schema
|
||||
// workoutObj.exercises = exercises.map(...);
|
||||
|
||||
return workoutObj;
|
||||
} catch (error) {
|
||||
console.error('Error getting workout details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of workouts
|
||||
*/
|
||||
async getWorkoutCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM workouts`
|
||||
);
|
||||
|
||||
return result?.count || 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting workout count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to map a database row to a Workout object
|
||||
*/
|
||||
private mapRowToWorkout(row: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated?: number;
|
||||
template_id?: string | null;
|
||||
total_volume?: number | null;
|
||||
total_reps?: number | null;
|
||||
source: string;
|
||||
}): Workout {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
type: row.type as any, // Cast to TemplateType
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
isCompleted: row.is_completed === 1,
|
||||
created_at: row.created_at,
|
||||
lastUpdated: row.last_updated,
|
||||
templateId: row.template_id || undefined,
|
||||
totalVolume: row.total_volume || undefined,
|
||||
totalReps: row.total_reps || undefined,
|
||||
availability: {
|
||||
source: [row.source as any] // Cast to StorageSource
|
||||
},
|
||||
exercises: [] // Exercises would be loaded separately
|
||||
};
|
||||
}
|
||||
}
|
11
package-lock.json
generated
11
package-lock.json
generated
@ -48,6 +48,7 @@
|
||||
"@rn-primitives/types": "~1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "^52.0.35",
|
||||
"expo-crypto": "~14.0.2",
|
||||
"expo-dev-client": "~5.0.12",
|
||||
@ -11292,6 +11293,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
@ -62,6 +62,7 @@
|
||||
"@rn-primitives/types": "~1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "^52.0.35",
|
||||
"expo-crypto": "~14.0.2",
|
||||
"expo-dev-client": "~5.0.12",
|
||||
|
Loading…
x
Reference in New Issue
Block a user