diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 99b42d8..300aca9 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -28,6 +28,7 @@ "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", + "expo-location": "~17.0.1", "expo-notifications": "~0.32.0", "expo-router": "~6.0.14", "expo-secure-store": "~15.0.7", @@ -7329,6 +7330,15 @@ "react-native": "*" } }, + "node_modules/expo-location": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-17.0.1.tgz", + "integrity": "sha512-m+OzotzlAXO3ZZ1uqW5GC25nXW868zN+ROyBA1V4VF6jGay1ZEs4URPglCVUDzZby2F5wt24cMzqDKw2IX6nRw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b485e96..fdc7654 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,6 +34,7 @@ "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", + "expo-location": "~17.0.1", "expo-notifications": "~0.32.0", "expo-router": "~6.0.14", "expo-secure-store": "~15.0.7", diff --git a/apps/mobile/src/app/(tabs)/attendance.tsx b/apps/mobile/src/app/(tabs)/attendance.tsx index 8ff1ebe..d069810 100644 --- a/apps/mobile/src/app/(tabs)/attendance.tsx +++ b/apps/mobile/src/app/(tabs)/attendance.tsx @@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons' import { attendanceApi, Attendance } from '../../api/attendance' import { theme } from '../../styles/theme' import { Animated } from 'react-native' +import { GeofenceStatus } from '../../components/GeofenceStatus' export default function AttendanceScreen() { const { getToken, userId } = useAuth() @@ -111,6 +112,9 @@ export default function AttendanceScreen() { Track your gym visits + {/* Geofencing Status Card */} + + {activeCheckIn ? ( void +} + +export const GeofenceStatus = ({ onStatusChange }: GeofenceStatusProps) => { + const [loading, setLoading] = useState(false) + const [isInside, setIsInside] = useState(false) + const [distance, setDistance] = useState('--') + const [permission, setPermission] = useState('undetermined') + const [lastCheck, setLastCheck] = useState(null) + + const checkGeofence = async () => { + try { + setLoading(true) + + // Request location permission + const { status } = await Location.requestForegroundPermissionsAsync() + setPermission(status) + + if (status !== 'granted') { + setLoading(false) + return + } + + // Get current location + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }) + + const { latitude, longitude } = location.coords + + // Check if within geofence + const within = isWithinGeofence(latitude, longitude, 500) + setIsInside(within) + onStatusChange?.(within) + + // Calculate distance + const dist = calculateDistance( + latitude, + longitude, + GYM_LOCATION.latitude, + GYM_LOCATION.longitude + ) + setDistance(getFormattedDistance(dist)) + setLastCheck(new Date()) + } catch (error) { + console.error('Geofence check error:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + checkGeofence() + // Auto-check every 30 seconds + const interval = setInterval(checkGeofence, 30000) + return () => clearInterval(interval) + }, []) + + return ( + + + + + + + {isInside ? 'At Gym' : 'Away from Gym'} + + + + {loading ? ( + + ) : ( + + )} + + + + + + Distance + {distance} + + + {lastCheck && ( + + Last check: {lastCheck.toLocaleTimeString()} + + )} + + + {permission !== 'granted' && ( + + + + Location permission required + + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + card: { + borderRadius: 16, + padding: 16, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#fff', + }, + refreshButton: { + padding: 8, + borderRadius: 8, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + content: { + gap: 12, + }, + stat: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + statLabel: { + fontSize: 14, + color: 'rgba(255, 255, 255, 0.8)', + fontWeight: '500', + }, + statValue: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, + lastCheck: { + fontSize: 12, + color: 'rgba(255, 255, 255, 0.7)', + marginTop: 4, + }, + permissionAlert: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 255, 255, 0.3)', + }, + permissionText: { + fontSize: 12, + color: '#fff', + fontWeight: '500', + }, +}) diff --git a/apps/mobile/src/services/geofencing.ts b/apps/mobile/src/services/geofencing.ts new file mode 100644 index 0000000..2c8a564 --- /dev/null +++ b/apps/mobile/src/services/geofencing.ts @@ -0,0 +1,74 @@ +/** + * Geofencing Service + * Handles gym location validation and distance calculations + */ + +// Gym location coordinates (example: gym location) +export const GYM_LOCATION = { + latitude: 37.7749, + longitude: -122.4194, +}; + +// Geofence radius in meters (500m default) +export const GEOFENCE_RADIUS_METERS = 500; + +/** + * Calculate distance between two coordinates using Haversine formula + * @param lat1 Starting latitude + * @param lon1 Starting longitude + * @param lat2 Ending latitude + * @param lon2 Ending longitude + * @returns Distance in meters + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371000; // Earth's radius in meters + const φ1 = (lat1 * Math.PI) / 180; + const φ2 = (lat2 * Math.PI) / 180; + const Δφ = ((lat2 - lat1) * Math.PI) / 180; + const Δλ = ((lon2 - lon1) * Math.PI) / 180; + + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; // Distance in meters +} + +/** + * Check if a location is within the geofence + * @param userLat User's latitude + * @param userLon User's longitude + * @param radius Geofence radius in meters (defaults to GEOFENCE_RADIUS_METERS) + * @returns Boolean indicating if user is within geofence + */ +export function isWithinGeofence( + userLat: number, + userLon: number, + radius: number = GEOFENCE_RADIUS_METERS +): boolean { + const distance = calculateDistance( + userLat, + userLon, + GYM_LOCATION.latitude, + GYM_LOCATION.longitude + ); + return distance <= radius; +} + +/** + * Get formatted distance string + * @param meters Distance in meters + * @returns Formatted distance string + */ +export function getFormattedDistance(meters: number): string { + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + return `${(meters / 1000).toFixed(2)} km`; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 5cc4325..a0c1326 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -12,7 +12,6 @@ "noEmit": true, "strict": true, "target": "esnext", - "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { "@/*": [