349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
ScrollView,
|
|
Alert,
|
|
} from "react-native";
|
|
import { useState, useEffect } from "react";
|
|
import { useAuth } from "@clerk/clerk-expo";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useTheme } from "../../contexts/ThemeContext";
|
|
import { MinimalCard } from "../../components/MinimalCard";
|
|
import { SectionHeader } from "../../components/SectionHeader";
|
|
import { MinimalButton } from "../../components/MinimalButton";
|
|
import { Badge } from "../../components/Badge";
|
|
import { IconContainer } from "../../components/IconContainer";
|
|
import { attendanceApi, Attendance } from "../../api/attendance";
|
|
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
|
import { getErrorMessage } from "../../utils/error-helpers";
|
|
import log from "../../utils/logger";
|
|
|
|
export default function AttendanceScreen() {
|
|
const { getToken, userId } = useAuth();
|
|
const { colors, typography } = useTheme();
|
|
const { clearCache: clearStatisticsCache } = useStatistics();
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
|
const [history, setHistory] = useState<Attendance[]>([]);
|
|
|
|
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);
|
|
clearStatisticsCache();
|
|
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);
|
|
clearStatisticsCache();
|
|
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, { backgroundColor: colors.background }]}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView
|
|
style={[styles.container, { backgroundColor: colors.background }]}
|
|
contentContainerStyle={styles.content}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text
|
|
style={[typography.h1, { color: colors.textPrimary, fontSize: 32 }]}
|
|
>
|
|
Attendance
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.body,
|
|
{ color: colors.textSecondary, marginTop: 8 },
|
|
]}
|
|
>
|
|
{activeCheckIn
|
|
? "You're crushing it today!"
|
|
: history.length === 0
|
|
? "Ready to start your fitness journey?"
|
|
: "Track your gym visits and build streaks!"}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Check In/Out Section */}
|
|
<View style={styles.section}>
|
|
{activeCheckIn ? (
|
|
<MinimalCard variant="bordered" style={styles.activeCard}>
|
|
<View style={styles.activeHeader}>
|
|
<View style={styles.activeHeaderLeft}>
|
|
<IconContainer
|
|
variant="colored"
|
|
backgroundColor={`${colors.success}20`}
|
|
size="lg"
|
|
>
|
|
<Ionicons
|
|
name="checkmark-circle"
|
|
size={28}
|
|
color={colors.success}
|
|
/>
|
|
</IconContainer>
|
|
<View style={{ marginLeft: 12 }}>
|
|
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
|
✅ Currently Checked In
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 2 },
|
|
]}
|
|
>
|
|
Since{" "}
|
|
{new Date(activeCheckIn.checkInTime).toLocaleTimeString(
|
|
[],
|
|
{
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
},
|
|
)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
<MinimalButton
|
|
title="Check Out"
|
|
onPress={handleCheckOut}
|
|
variant="danger"
|
|
size="lg"
|
|
style={{ marginTop: 16 }}
|
|
/>
|
|
</MinimalCard>
|
|
) : (
|
|
<MinimalButton
|
|
title="💪 Check In"
|
|
onPress={handleCheckIn}
|
|
variant="primary"
|
|
size="lg"
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* Attendance Calendar */}
|
|
<View style={styles.section}>
|
|
<SectionHeader title="📅 Calendar" />
|
|
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
<AttendanceCalendar attendanceRecords={history} />
|
|
</MinimalCard>
|
|
</View>
|
|
|
|
{/* Recent History */}
|
|
<View style={styles.section}>
|
|
<SectionHeader title="📊 Recent History" />
|
|
{history.length === 0 ? (
|
|
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
<View style={styles.emptyState}>
|
|
<Text style={{ fontSize: 64 }}>📍</Text>
|
|
<Text
|
|
style={[
|
|
typography.bodyEmphasis,
|
|
{ color: colors.textPrimary, marginTop: 16 },
|
|
]}
|
|
>
|
|
No attendance history yet
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.body,
|
|
{
|
|
color: colors.textSecondary,
|
|
marginTop: 8,
|
|
textAlign: "center",
|
|
},
|
|
]}
|
|
>
|
|
Check in to start building your streak! 🔥
|
|
</Text>
|
|
</View>
|
|
</MinimalCard>
|
|
) : (
|
|
<View style={styles.historyList}>
|
|
{history.slice(0, 10).map((record, index) => {
|
|
const checkIn = new Date(record.checkInTime);
|
|
const checkOut = record.checkOutTime
|
|
? new Date(record.checkOutTime)
|
|
: null;
|
|
const duration = checkOut
|
|
? Math.round((checkOut.getTime() - checkIn.getTime()) / 60000)
|
|
: null;
|
|
|
|
return (
|
|
<MinimalCard
|
|
key={index}
|
|
variant="bordered"
|
|
style={{ borderRadius: 16 }}
|
|
>
|
|
<View style={styles.historyItem}>
|
|
<View style={styles.historyLeft}>
|
|
<IconContainer
|
|
variant="colored"
|
|
backgroundColor={
|
|
checkOut ? `${colors.success}20` : `${colors.info}20`
|
|
}
|
|
>
|
|
<Ionicons
|
|
name={checkOut ? "checkmark-done" : "time"}
|
|
size={20}
|
|
color={checkOut ? colors.success : colors.info}
|
|
/>
|
|
</IconContainer>
|
|
<View style={{ marginLeft: 12, flex: 1 }}>
|
|
<Text
|
|
style={[typography.h3, { color: colors.textPrimary }]}
|
|
>
|
|
{checkIn.toLocaleDateString()}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
typography.caption,
|
|
{ color: colors.textTertiary, marginTop: 2 },
|
|
]}
|
|
>
|
|
{checkIn.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
{checkOut &&
|
|
` - ${checkOut.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}`}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{duration && (
|
|
<Badge
|
|
label={`${duration}m`}
|
|
variant="neutral"
|
|
size="sm"
|
|
/>
|
|
)}
|
|
</View>
|
|
</MinimalCard>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Bottom Spacer */}
|
|
<View style={{ height: 100 }} />
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
content: {
|
|
paddingBottom: 20,
|
|
},
|
|
header: {
|
|
paddingHorizontal: 24,
|
|
paddingTop: 60,
|
|
paddingBottom: 24,
|
|
},
|
|
section: {
|
|
paddingHorizontal: 24,
|
|
marginBottom: 24,
|
|
},
|
|
activeCard: {
|
|
padding: 20,
|
|
borderRadius: 20,
|
|
},
|
|
activeHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
activeHeaderLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
},
|
|
emptyState: {
|
|
alignItems: "center",
|
|
paddingVertical: 40,
|
|
paddingHorizontal: 20,
|
|
},
|
|
historyList: {
|
|
gap: 12,
|
|
},
|
|
historyItem: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
},
|
|
historyLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
},
|
|
});
|