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": {
"@/*": [