395 lines
11 KiB
TypeScript
395 lines
11 KiB
TypeScript
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
ScrollView,
|
|
Alert,
|
|
} from "react-native";
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { useAuth } from "@clerk/clerk-expo";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { attendanceApi, Attendance } from "../../api/attendance";
|
|
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
|
import { theme } from "../../styles/theme";
|
|
import { Animated } from "react-native";
|
|
import { getErrorMessage } from "../../utils/error-helpers";
|
|
import log from "../../utils/logger";
|
|
|
|
export default function AttendanceScreen() {
|
|
const { getToken, userId } = useAuth();
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
|
const [history, setHistory] = useState<Attendance[]>([]);
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
|
|
useEffect(() => {
|
|
if (activeCheckIn) {
|
|
const pulse = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1.05,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
);
|
|
pulse.start();
|
|
return () => pulse.stop();
|
|
}
|
|
}, [activeCheckIn]);
|
|
|
|
const fetchAttendance = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const token = await getToken();
|
|
if (!token) return;
|
|
|
|
log.debug("Fetching attendance history");
|
|
const data = await attendanceApi.getHistory(token);
|
|
setHistory(data);
|
|
|
|
// Check if there's an active check-in (latest one has no checkOutTime)
|
|
if (data.length > 0 && !data[0].checkOutTime) {
|
|
setActiveCheckIn(data[0]);
|
|
} else {
|
|
setActiveCheckIn(null);
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to fetch attendance", error);
|
|
Alert.alert("Error", "Failed to load attendance data");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchAttendance();
|
|
}, []);
|
|
|
|
const handleCheckIn = async () => {
|
|
try {
|
|
const token = await getToken();
|
|
if (!token) return;
|
|
|
|
await attendanceApi.checkIn("gym", token);
|
|
fetchAttendance();
|
|
Alert.alert("Success", "Checked in successfully!");
|
|
} catch (error: unknown) {
|
|
log.error("Failed to check in", error);
|
|
Alert.alert("Error", getErrorMessage(error, "Failed to check in"));
|
|
}
|
|
};
|
|
|
|
const handleCheckOut = async () => {
|
|
try {
|
|
const token = await getToken();
|
|
if (!token) return;
|
|
|
|
await attendanceApi.checkOut(token);
|
|
fetchAttendance();
|
|
Alert.alert("Success", "Checked out successfully!");
|
|
} catch (error: unknown) {
|
|
log.error("Failed to check out", error);
|
|
Alert.alert("Error", getErrorMessage(error, "Failed to check out"));
|
|
}
|
|
};
|
|
|
|
if (loading && !history.length) {
|
|
return (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="large" color={theme.colors.primary} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
<LinearGradient
|
|
colors={theme.gradients.primary}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.header}
|
|
>
|
|
<Text style={styles.title}>Attendance</Text>
|
|
<Text style={styles.subtitle}>Track your gym visits</Text>
|
|
</LinearGradient>
|
|
|
|
<View style={styles.actionContainer}>
|
|
{activeCheckIn ? (
|
|
<LinearGradient
|
|
colors={["rgba(16, 185, 129, 0.15)", "rgba(5, 150, 105, 0.1)"]}
|
|
style={[styles.activeCard, theme.shadows.medium]}
|
|
>
|
|
<View style={styles.activeCardContent}>
|
|
<View style={styles.activeIconContainer}>
|
|
<LinearGradient
|
|
colors={theme.gradients.success}
|
|
style={styles.activeIcon}
|
|
>
|
|
<Ionicons name="checkmark-circle" size={32} color="#fff" />
|
|
</LinearGradient>
|
|
</View>
|
|
<View style={styles.activeTextContainer}>
|
|
<Text style={styles.activeText}>Currently Checked In</Text>
|
|
<Text style={styles.timeText}>
|
|
Since{" "}
|
|
{new Date(activeCheckIn.checkInTime).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity onPress={handleCheckOut} activeOpacity={0.8}>
|
|
<LinearGradient
|
|
colors={theme.gradients.danger}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={[styles.checkOutButton, theme.shadows.medium]}
|
|
>
|
|
<Ionicons
|
|
name="log-out-outline"
|
|
size={20}
|
|
color="#fff"
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text style={styles.buttonText}>Check Out</Text>
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
</LinearGradient>
|
|
) : (
|
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
|
<TouchableOpacity onPress={handleCheckIn} activeOpacity={0.8}>
|
|
<LinearGradient
|
|
colors={theme.gradients.primary}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={[styles.checkInButton, theme.shadows.glow]}
|
|
>
|
|
<Ionicons
|
|
name="log-in-outline"
|
|
size={24}
|
|
color="#fff"
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text style={styles.checkInButtonText}>Check In</Text>
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Attendance Calendar */}
|
|
{history.length > 0 && <AttendanceCalendar attendanceRecords={history} />}
|
|
|
|
<Text style={styles.sectionTitle}>Recent History</Text>
|
|
{history.map((item) => (
|
|
<LinearGradient
|
|
key={item.id}
|
|
colors={["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const}
|
|
style={[styles.historyItem, theme.shadows.medium]}
|
|
>
|
|
<View style={styles.historyLeft}>
|
|
<View style={styles.historyIconContainer}>
|
|
<LinearGradient
|
|
colors={
|
|
item.checkOutTime
|
|
? theme.gradients.success
|
|
: theme.gradients.primary
|
|
}
|
|
style={styles.historyIcon}
|
|
>
|
|
<Ionicons
|
|
name={item.checkOutTime ? "checkmark" : "time-outline"}
|
|
size={16}
|
|
color="#fff"
|
|
/>
|
|
</LinearGradient>
|
|
</View>
|
|
<View>
|
|
<Text style={styles.dateText}>
|
|
{new Date(item.checkInTime).toLocaleDateString()}
|
|
</Text>
|
|
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.timeContainer}>
|
|
<Text style={styles.historyTime}>
|
|
In:{" "}
|
|
{new Date(item.checkInTime).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</Text>
|
|
{item.checkOutTime && (
|
|
<Text style={styles.historyTime}>
|
|
Out:{" "}
|
|
{new Date(item.checkOutTime).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</LinearGradient>
|
|
))}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.background,
|
|
},
|
|
content: {
|
|
paddingBottom: 20,
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
header: {
|
|
paddingTop: 60,
|
|
paddingBottom: 24,
|
|
paddingHorizontal: 24,
|
|
marginBottom: 24,
|
|
borderBottomLeftRadius: theme.borderRadius.xl,
|
|
borderBottomRightRadius: theme.borderRadius.xl,
|
|
},
|
|
title: {
|
|
fontSize: theme.typography.fontSize["3xl"],
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
color: theme.colors.white,
|
|
marginBottom: 4,
|
|
},
|
|
subtitle: {
|
|
fontSize: theme.typography.fontSize.base,
|
|
color: "rgba(255, 255, 255, 0.9)",
|
|
},
|
|
actionContainer: {
|
|
marginBottom: 32,
|
|
paddingHorizontal: 20,
|
|
},
|
|
checkInButton: {
|
|
paddingVertical: 20,
|
|
paddingHorizontal: 24,
|
|
borderRadius: theme.borderRadius.xl,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
checkInButtonText: {
|
|
color: theme.colors.white,
|
|
fontSize: theme.typography.fontSize.xl,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
},
|
|
checkOutButton: {
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 20,
|
|
borderRadius: theme.borderRadius.lg,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginTop: 16,
|
|
},
|
|
buttonText: {
|
|
color: theme.colors.white,
|
|
fontSize: theme.typography.fontSize.base,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
},
|
|
activeCard: {
|
|
padding: 20,
|
|
borderRadius: theme.borderRadius.xl,
|
|
borderWidth: 1,
|
|
borderColor: "rgba(16, 185, 129, 0.2)",
|
|
},
|
|
activeCardContent: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginBottom: 16,
|
|
},
|
|
activeIconContainer: {
|
|
marginRight: 16,
|
|
},
|
|
activeIcon: {
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
activeTextContainer: {
|
|
flex: 1,
|
|
},
|
|
activeText: {
|
|
fontSize: theme.typography.fontSize.lg,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
color: theme.colors.gray900,
|
|
marginBottom: 4,
|
|
},
|
|
timeText: {
|
|
fontSize: theme.typography.fontSize.sm,
|
|
color: theme.colors.gray600,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: theme.typography.fontSize.xl,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
marginBottom: 16,
|
|
paddingHorizontal: 20,
|
|
color: theme.colors.gray900,
|
|
},
|
|
historyItem: {
|
|
padding: 16,
|
|
borderRadius: theme.borderRadius.xl,
|
|
marginBottom: 12,
|
|
marginHorizontal: 20,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
borderWidth: 1,
|
|
borderColor: "rgba(59, 130, 246, 0.1)",
|
|
},
|
|
historyLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
},
|
|
historyIconContainer: {
|
|
marginRight: 4,
|
|
},
|
|
historyIcon: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
dateText: {
|
|
fontSize: theme.typography.fontSize.base,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
color: theme.colors.gray900,
|
|
},
|
|
typeText: {
|
|
fontSize: theme.typography.fontSize.xs,
|
|
color: theme.colors.gray600,
|
|
marginTop: 2,
|
|
},
|
|
timeContainer: {
|
|
alignItems: "flex-end",
|
|
},
|
|
historyTime: {
|
|
fontSize: theme.typography.fontSize.sm,
|
|
color: theme.colors.gray700,
|
|
},
|
|
});
|