diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 0000000..d25daf9 --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,193 @@ +name: Build and Deploy Mobile App + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + workflow_dispatch: + inputs: + platform: + description: 'Platform to build' + required: true + default: 'both' + type: choice + options: + - both + - android + - ios + +jobs: + build-android: + name: Build Android + runs-on: ubuntu-latest + if: github.event.inputs.platform != 'ios' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: mobile/package-lock.json + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install dependencies + working-directory: mobile + run: npm ci + + - name: Setup Expo + working-directory: mobile + run: | + npm install -g expo-cli + npx expo prebuild --platform android --clean + + - name: Decode Android Keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > mobile/android/app/release.keystore + + - name: Build Android APK + working-directory: mobile/android + env: + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + ./gradlew assembleRelease + + - name: Build Android AAB (Bundle) + working-directory: mobile/android + env: + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + ./gradlew bundleRelease + + - name: Upload Android APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: mobile/android/app/build/outputs/apk/release/app-release.apk + + - name: Upload Android AAB + uses: actions/upload-artifact@v4 + with: + name: android-aab + path: mobile/android/app/build/outputs/bundle/release/app-release.aab + + - name: Create GitHub Release (on tag) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v1 + with: + files: | + mobile/android/app/build/outputs/apk/release/app-release.apk + mobile/android/app/build/outputs/bundle/release/app-release.aab + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-ios: + name: Build iOS + runs-on: macos-14 + if: github.event.inputs.platform != 'android' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: mobile/package-lock.json + + - name: Install dependencies + working-directory: mobile + run: npm ci + + - name: Setup Expo + working-directory: mobile + run: | + npm install -g expo-cli + npx expo prebuild --platform ios --clean + + - name: Install CocoaPods + working-directory: mobile/ios + run: | + pod install + + - name: Import Code Signing Certificates + uses: apple-actions/import-codesign-certs@v2 + with: + p12-file-base64: ${{ secrets.IOS_CERTIFICATES_P12 }} + p12-password: ${{ secrets.IOS_CERTIFICATES_PASSWORD }} + + - name: Download Provisioning Profiles + env: + IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + run: | + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/app.mobileprovision + + - name: Build iOS Archive + working-directory: mobile/ios + run: | + xcodebuild \ + -workspace application.xcworkspace \ + -scheme application \ + -configuration Release \ + -archivePath $PWD/build/application.xcarchive \ + archive + + - name: Export IPA + working-directory: mobile/ios + run: | + xcodebuild \ + -exportArchive \ + -archivePath $PWD/build/application.xcarchive \ + -exportPath $PWD/build \ + -exportOptionsPlist ExportOptions.plist + + - name: Upload iOS IPA + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: mobile/ios/build/*.ipa + + - name: Upload to TestFlight + if: startsWith(github.ref, 'refs/tags/v') + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }} + run: | + echo "$APP_STORE_CONNECT_API_KEY_CONTENT" > AuthKey.p8 + xcrun altool --upload-app \ + --type ios \ + --file mobile/ios/build/*.ipa \ + --apiKey $APP_STORE_CONNECT_API_KEY_ID \ + --apiIssuer $APP_STORE_CONNECT_API_ISSUER_ID + + - name: Create GitHub Release (on tag) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v1 + with: + files: mobile/ios/build/*.ipa + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9a5aced..b39ea0c 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,16 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Expo prebuild generated folders (älä commitoi) +mobile/android/ +mobile/ios/ + +# Signing files (turvallisuussyistä) +*.keystore +*.p12 +*.mobileprovision +*.p8 +*.cer +*.certSigningRequest +*-base64.txt diff --git a/mobile/app.json b/mobile/app.json index 81c9cb8..6a065d6 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -1,26 +1,50 @@ { "expo": { "name": "MyPet", - "slug": "mypet-mobile", + "slug": "mypet", "version": "1.0.0", "orientation": "portrait", "entryPoint": "./index.ts", - "assetBundlePatterns": ["**/*"], - "ios": { - "supportsTabletMode": true, - "supportsSmallDevice": true - }, + "assetBundlePatterns": [ + "**/*" + ], "android": { "package": "com.mypet.mobile", + "versionCode": 1, "permissions": [ "android.permission.INTERNET", "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION", + "android.permission.ACTIVITY_RECOGNITION" ] }, - "plugins": [], + "ios": { + "bundleIdentifier": "com.mypet.mobile", + "buildNumber": "1", + "supportsTabletMode": true, + "supportsSmallDevice": true, + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Tarvitsemme sijaintitietojasi lenkkien seurantaan", + "NSLocationAlwaysAndWhenInUseUsageDescription": "Tarvitsemme sijaintitietojasi lenkkien seurantaan taustalla", + "NSMotionUsageDescription": "Tarvitsemme liikeanturitietoja askeleiden laskemiseen" + } + }, + "plugins": [ + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Tarvitsemme sijaintitietojasi lenkkien seurantaan.", + "locationAlwaysPermission": "Tarvitsemme sijaintitietojasi lenkkien seurantaan taustalla.", + "locationWhenInUsePermission": "Tarvitsemme sijaintitietojasi lenkkien seurantaan." + } + ] + ], "web": { "favicon": "./src/assets/favicon.png" - } + }, + "owner": "mypet-organization" } } diff --git a/mobile/package.json b/mobile/package.json index dd476e8..db3dde9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -5,24 +5,27 @@ "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "eject": "expo eject", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "type-check": "tsc --noEmit" }, "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.10.0", "@react-navigation/native": "^7.1.0", "@react-navigation/native-stack": "^7.10.0", "axios": "^1.7.9", - "expo": "~54.0.0", + "expo": "^54.0.32", + "expo-location": "^19.0.8", + "expo-sensors": "^15.0.8", "expo-status-bar": "~3.0.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "^1.20.1", "react-native-paper": "^5.12.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index e5f07a9..9ad5df7 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -4,6 +4,7 @@ import { PaperProvider } from 'react-native-paper'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { AuthProvider } from '@contexts/AuthContext'; import { SnackbarProvider } from '@contexts/SnackbarContext'; +import { WalkProvider } from '@contexts/WalkContext'; import Navigation from '@navigation/Navigation'; import MD3Theme from './styles/theme'; @@ -14,7 +15,9 @@ export default function App() { - + + + diff --git a/mobile/src/contexts/WalkContext.tsx b/mobile/src/contexts/WalkContext.tsx new file mode 100644 index 0000000..b432a69 --- /dev/null +++ b/mobile/src/contexts/WalkContext.tsx @@ -0,0 +1,258 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import * as Location from 'expo-location'; +import { Pedometer } from 'expo-sensors'; +import { Walk, Coordinate, WalkStats, WalkSettings } from '../types'; +import { storageService } from '../services/storageService'; +import { locationService } from '../services/locationService'; + +interface WalkContextType { + // Current walk state + isTracking: boolean; + currentWalk: Walk | null; + currentCoordinates: Coordinate[]; + currentStats: WalkStats; + + // Walk history + walks: Walk[]; + + // Settings + settings: WalkSettings; + updateSettings: (settings: WalkSettings) => Promise; + + // Actions + startWalk: (petId: string, petName: string) => Promise; + stopWalk: () => Promise; + pauseWalk: () => void; + resumeWalk: () => void; + deleteWalk: (id: string) => Promise; + refreshWalks: () => Promise; + + // Permission + hasLocationPermission: boolean; + requestLocationPermission: () => Promise; +} + +const WalkContext = createContext(undefined); + +export function WalkProvider({ children }: { children: ReactNode }) { + const [isTracking, setIsTracking] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [currentWalk, setCurrentWalk] = useState(null); + const [currentCoordinates, setCurrentCoordinates] = useState([]); + const [currentStats, setCurrentStats] = useState({ + distance: 0, + duration: 0, + averageSpeed: 0, + steps: 0, + calories: 0, + }); + const [walks, setWalks] = useState([]); + const [settings, setSettings] = useState({ + enableSync: false, + autoStartOnMovement: false, + trackSteps: true, + }); + const [hasLocationPermission, setHasLocationPermission] = useState(false); + const [currentPetId, setCurrentPetId] = useState(''); + const [currentPetName, setCurrentPetName] = useState(''); + + const [locationSubscription, setLocationSubscription] = useState(null); + const [pedometerSubscription, setPedometerSubscription] = useState(null); + const [startTime, setStartTime] = useState(0); + + // Load walks and settings on mount + useEffect(() => { + loadWalks(); + loadSettings(); + checkLocationPermission(); + }, []); + + // Update duration while tracking + useEffect(() => { + if (!isTracking || isPaused) return; + + const interval = setInterval(() => { + const duration = Math.floor((Date.now() - startTime) / 1000); + setCurrentStats((prev: WalkStats) => ({ ...prev, duration })); + }, 1000); + + return () => clearInterval(interval); + }, [isTracking, isPaused, startTime]); + + const checkLocationPermission = async () => { + const { status } = await Location.getForegroundPermissionsAsync(); + setHasLocationPermission(status === 'granted'); + }; + + const requestLocationPermission = async (): Promise => { + const granted = await locationService.requestPermissions(); + setHasLocationPermission(granted); + return granted; + }; + + const loadWalks = async () => { + const loadedWalks = await storageService.getWalks(); + setWalks(loadedWalks); + }; + + const loadSettings = async () => { + const loadedSettings = await storageService.getWalkSettings(); + setSettings(loadedSettings); + }; + + const updateSettings = async (newSettings: WalkSettings) => { + await storageService.saveWalkSettings(newSettings); + setSettings(newSettings); + }; + + const startWalk = async (petId: string, petName: string) => { + if (!hasLocationPermission) { + const granted = await requestLocationPermission(); + if (!granted) { + throw new Error('Location permission required'); + } + } + + const now = Date.now(); + setStartTime(now); + setIsTracking(true); + setIsPaused(false); + setCurrentPetId(petId); + setCurrentPetName(petName); + setCurrentCoordinates([]); + setCurrentStats({ + distance: 0, + duration: 0, + averageSpeed: 0, + steps: 0, + calories: 0, + }); + + // Start location tracking + const subscription = await locationService.startLocationTracking( + (coordinate) => { + if (!isPaused) { + setCurrentCoordinates(prev => { + const newCoords = [...prev, coordinate]; + + // Calculate distance + const distance = locationService.calculateTotalDistance(newCoords); + const duration = Math.floor((Date.now() - startTime) / 1000); + const averageSpeed = duration > 0 ? (distance / 1000) / (duration / 3600) : 0; + + setCurrentStats((prevStats: WalkStats) => ({ + ...prevStats, + distance, + averageSpeed, + })); + + return newCoords; + }); + } + } + ); + setLocationSubscription(subscription); + + // Start step tracking if enabled + if (settings.trackSteps) { + const isPedometerAvailable = await Pedometer.isAvailableAsync(); + if (isPedometerAvailable) { + const subscription = Pedometer.watchStepCount(result => { + const steps = result.steps; + const calories = steps * 0.04; // Approximate calories + setCurrentStats((prev: WalkStats) => ({ + ...prev, + steps, + calories: Math.round(calories), + })); + }); + setPedometerSubscription(subscription); + } + } + }; + + const stopWalk = async () => { + const endTime = Date.now(); + + // Stop tracking + if (locationSubscription) { + locationSubscription.remove(); + setLocationSubscription(null); + } + + if (pedometerSubscription) { + pedometerSubscription.remove(); + setPedometerSubscription(null); + } + + // Create walk object + const walk: Walk = { + id: `walk_${endTime}`, + startTime, + endTime, + coordinates: currentCoordinates, + stats: currentStats, + petId: currentPetId, + petName: currentPetName, + synced: false, + }; + + // Save to storage + await storageService.saveWalk(walk); + + // Update state + setWalks(prev => [...prev, walk]); + setIsTracking(false); + setIsPaused(false); + setCurrentWalk(walk); + }; + + const pauseWalk = () => { + setIsPaused(true); + }; + + const resumeWalk = () => { + setIsPaused(false); + }; + + const deleteWalk = async (id: string) => { + await storageService.deleteWalk(id); + setWalks(prev => prev.filter(walk => walk.id !== id)); + }; + + const refreshWalks = async () => { + await loadWalks(); + }; + + return ( + + {children} + + ); +} + +export function useWalk() { + const context = useContext(WalkContext); + if (context === undefined) { + throw new Error('useWalk must be used within a WalkProvider'); + } + return context; +} diff --git a/mobile/src/contexts/index.ts b/mobile/src/contexts/index.ts index 6251f8d..d447d47 100644 --- a/mobile/src/contexts/index.ts +++ b/mobile/src/contexts/index.ts @@ -1,3 +1,4 @@ // Context API providers export { AuthProvider, useAuth } from './AuthContext'; export { SnackbarProvider, useSnackbar } from './SnackbarContext'; +export { WalkProvider, useWalk } from './WalkContext'; diff --git a/mobile/src/navigation/Navigation.tsx b/mobile/src/navigation/Navigation.tsx index 9b48f1a..9eb758f 100644 --- a/mobile/src/navigation/Navigation.tsx +++ b/mobile/src/navigation/Navigation.tsx @@ -12,6 +12,9 @@ import SettingsScreen from '@screens/SettingsScreen'; import ProfileScreen from '@screens/ProfileScreen'; import LoginScreen from '@screens/LoginScreen'; import RegisterScreen from '@screens/RegisterScreen'; +import MapScreen from '@screens/MapScreen'; +import WalkHistoryScreen from '@screens/WalkHistoryScreen'; +import WalkDetailScreen from '@screens/WalkDetailScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -73,6 +76,8 @@ function HomeTabs() { iconName = 'home'; } else if (route.name === 'PetsTab') { iconName = 'paw'; + } else if (route.name === 'MapTab') { + iconName = 'map-marker-path'; } else if (route.name === 'SettingsTab') { iconName = 'cog'; } else { @@ -84,6 +89,13 @@ function HomeTabs() { tabBarActiveTintColor: COLORS.primary, tabBarInactiveTintColor: COLORS.onSurfaceVariant, headerShown: false, + tabBarStyle: route.name === 'MapTab' ? { + position: 'absolute', + backgroundColor: 'rgba(255, 255, 255, 0.35)', + borderTopColor: 'rgba(0, 0, 0, 0.1)', + } : { + backgroundColor: COLORS.surface, + }, })} > + + + + navigation.navigate('WalkHistory' as never)}> + + + Lenkit + + {walks.length} tallennettua lenkkiä + + + + console.log('Sijainti')}> diff --git a/mobile/src/screens/MapScreen.tsx b/mobile/src/screens/MapScreen.tsx new file mode 100644 index 0000000..4819493 --- /dev/null +++ b/mobile/src/screens/MapScreen.tsx @@ -0,0 +1,626 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert, Modal, Platform } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from 'react-native-maps'; +import * as Location from 'expo-location'; +import { useWalk } from '@contexts/WalkContext'; +import { Pet } from '../types'; +import { COLORS, SPACING, TYPOGRAPHY, LAYOUT } from '../styles/theme'; + +export default function MapScreen() { + const { + isTracking, + currentStats, + currentCoordinates, + startWalk, + stopWalk, + pauseWalk, + resumeWalk, + hasLocationPermission, + requestLocationPermission, + } = useWalk(); + + const [isPaused, setIsPaused] = useState(false); + const [showPetSelector, setShowPetSelector] = useState(false); + const [selectedPets, setSelectedPets] = useState([]); + const [pets, setPets] = useState([]); + const mapRef = useRef(null); + + useEffect(() => { + if (!hasLocationPermission) { + requestLocationPermission(); + } + // TODO: Hae lemmikit backendistä + // Väliaikaisesti kovakoodatut lemmikit + setPets([ + { id: '1', name: 'Macho', breed: 'Akita', age: 12, weight: 44, dateOfBirth: '2013-10-01' }, + { id: '2', name: 'Mirri', breed: 'Sekarotuinen', age: 2, weight: 15, dateOfBirth: '2022-06-15' }, + ]); + + // Hae käyttäjän sijainti heti + getCurrentLocation(); + }, []); + + const getCurrentLocation = async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + return; + } + + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + + const userPos = { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }; + + // Keskitä kartta käyttäjän sijaintiin + if (mapRef.current) { + mapRef.current.animateToRegion({ + latitude: userPos.latitude, + longitude: userPos.longitude, + latitudeDelta: 0.01, + longitudeDelta: 0.01, + }, 1000); + } + } catch (error) { + console.error('Error getting location:', error); + } + }; + + useEffect(() => { + if (currentCoordinates.length > 0) { + const latest = currentCoordinates[currentCoordinates.length - 1]; + + // Keskitä kartta käyttäjän sijaintiin + if (mapRef.current) { + mapRef.current.animateToRegion({ + latitude: latest.latitude, + longitude: latest.longitude, + latitudeDelta: 0.01, + longitudeDelta: 0.01, + }, 1000); + } + } + }, [currentCoordinates]); + + const handleSelectPet = () => { + if (pets.length === 0) { + Alert.alert('Ei lemmikkejä', 'Lisää ensin lemmikki profiilissa'); + return; + } + setShowPetSelector(true); + }; + + const handleStartWalk = async (pet: Pet) => { + try { + // Toggle pet selection + const isAlreadySelected = selectedPets.some(p => p.id === pet.id); + + if (isAlreadySelected) { + setSelectedPets(selectedPets.filter(p => p.id !== pet.id)); + } else { + setSelectedPets([...selectedPets, pet]); + } + } catch (error: any) { + Alert.alert('Virhe', error.message || 'Lemmikin valinta epäonnistui'); + } + }; + + const handleConfirmPets = async () => { + if (selectedPets.length === 0) { + Alert.alert('Virhe', 'Valitse vähintään yksi lemmikki'); + return; + } + + try { + const petNames = selectedPets.map(p => p.name).join(', '); + const petIds = selectedPets.map(p => p.id).join(','); + + setShowPetSelector(false); + await startWalk(petIds, petNames); + } catch (error: any) { + Alert.alert('Virhe', error.message || 'Lenkin aloitus epäonnistui'); + } + }; + + const handleStopWalk = async () => { + const petNames = selectedPets.map(p => p.name).join(', '); + Alert.alert( + 'Lopeta lenkki', + `Haluatko varmasti lopettaa lenkin${selectedPets.length > 1 ? ' lemmikeille' : ''}: ${petNames}?`, + [ + { text: 'Peruuta', style: 'cancel' }, + { + text: 'Lopeta', + style: 'destructive', + onPress: async () => { + await stopWalk(); + setSelectedPets([]); + Alert.alert('Lenkki tallennettu', 'Lenkki on tallennettu laitteen muistiin'); + }, + }, + ] + ); + }; + + const handlePauseResume = () => { + if (isPaused) { + resumeWalk(); + setIsPaused(false); + } else { + pauseWalk(); + setIsPaused(true); + } + }; + + const formatDuration = (seconds: number): string => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hrs > 0) { + return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const formatDistance = (meters: number): string => { + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + return `${(meters / 1000).toFixed(2)} km`; + }; + + return ( + + {/* Kokoruutu kartta */} + + {/* Lenkin reitti */} + {currentCoordinates.length > 1 && ( + ({ + latitude: c.latitude, + longitude: c.longitude, + }))} + strokeColor={COLORS.primary} + strokeWidth={4} + /> + )} + + {/* Aloituspiste */} + {currentCoordinates.length > 0 && ( + + )} + + + {/* Yläosan tilastopalkki */} + {isTracking && ( + + {selectedPets.length > 0 && ( + + + + {selectedPets.map(p => p.name).join(', ')} + + + )} + + + + + {formatDistance(currentStats.distance)} + + + + + + + {formatDuration(currentStats.duration)} + + + + + + + {(currentStats.averageSpeed / 3.6).toFixed(1)} m/s + + + + + + + {currentStats.steps || 0} + + + + )} + + {/* Control Panel - alaosassa läpinäkyvänä */} + {isTracking && ( + + + + {isPaused ? 'Jatka' : 'Tauko'} + + + + + Lopeta + + + )} + + {!isTracking && ( + + + + Aloita lenkki + + + )} + + {/* Pet Selection Modal */} + setShowPetSelector(false)} + > + + + Valitse lemmikki(t) + Voit valita useamman lemmikin kerralla + + {pets.map((pet) => { + const isSelected = selectedPets.some(p => p.id === pet.id); + return ( + handleStartWalk(pet)} + activeOpacity={0.7} + > + + + {pet.name} + {pet.breed} + + + ); + })} + + + + + Aloita lenkki{selectedPets.length > 0 ? ` (${selectedPets.length})` : ''} + + + { + setShowPetSelector(false); + setSelectedPets([]); + }} + > + Peruuta + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + map: { + flex: 1, + }, + statsPanel: { + backgroundColor: COLORS.surface, + borderTopLeftRadius: LAYOUT.radiusLg, + borderTopRightRadius: LAYOUT.radiusLg, + paddingTop: SPACING.lg, + paddingHorizontal: SPACING.md, + maxHeight: 250, + elevation: 8, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + gap: SPACING.sm, + }, + statCard: { + backgroundColor: COLORS.primaryContainer, + borderRadius: LAYOUT.radiusMd, + padding: SPACING.md, + width: '48%', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + topBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: COLORS.primary, + paddingTop: Platform.OS === 'android' ? SPACING.xl + 30 : SPACING.xl + 20, + paddingBottom: SPACING.md, + paddingHorizontal: SPACING.md, + borderBottomLeftRadius: LAYOUT.radiusLg, + borderBottomRightRadius: LAYOUT.radiusLg, + elevation: 8, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + petInfoTop: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: SPACING.xs, + marginBottom: SPACING.sm, + }, + petInfoTopText: { + ...TYPOGRAPHY.titleMedium, + color: COLORS.onPrimary, + fontWeight: '600', + }, + statsBar: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + }, + statItem: { + alignItems: 'center', + gap: SPACING.xs, + }, + statValue: { + ...TYPOGRAPHY.labelMedium, + color: COLORS.onPrimary, + fontWeight: '600', + }, + statDivider: { + width: 1, + height: 30, + backgroundColor: COLORS.onPrimary, + opacity: 0.3, + }, + controlPanel: { + position: 'absolute', + bottom: SPACING.lg, + left: SPACING.lg, + right: SPACING.lg, + paddingBottom: SPACING.xl + 20, + }, + startButton: { + backgroundColor: COLORS.primary, + paddingVertical: SPACING.lg, + borderRadius: LAYOUT.radiusLg, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + elevation: 4, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }, + startButtonText: { + ...TYPOGRAPHY.labelLarge, + color: COLORS.onPrimary, + marginLeft: SPACING.sm, + fontWeight: '600', + }, + bottomControls: { + position: 'absolute', + bottom: 90, + left: SPACING.lg, + right: SPACING.lg, + flexDirection: 'row', + gap: SPACING.md, + }, + pauseButton: { + backgroundColor: COLORS.secondary, + borderRadius: LAYOUT.radiusLg, + padding: SPACING.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: SPACING.sm, + flex: 1, + elevation: 8, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + pauseButtonText: { + ...TYPOGRAPHY.labelLarge, + color: COLORS.onSecondary, + fontWeight: '600', + }, + stopButton: { + backgroundColor: COLORS.error, + borderRadius: LAYOUT.radiusLg, + padding: SPACING.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: SPACING.sm, + flex: 1, + elevation: 8, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + stopButtonText: { + ...TYPOGRAPHY.labelLarge, + color: COLORS.onError, + marginLeft: SPACING.sm, + fontWeight: '600', + }, + petInfo: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: COLORS.primaryContainer, + padding: SPACING.sm, + borderRadius: LAYOUT.radiusMd, + marginBottom: SPACING.md, + gap: SPACING.xs, + }, + petInfoText: { + ...TYPOGRAPHY.labelMedium, + color: COLORS.onPrimaryContainer, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: COLORS.surface, + borderTopLeftRadius: LAYOUT.radiusLg, + borderTopRightRadius: LAYOUT.radiusLg, + padding: SPACING.lg, + maxHeight: '70%', + }, + modalTitle: { + ...TYPOGRAPHY.headlineSmall, + color: COLORS.onSurface, + marginBottom: SPACING.xs, + textAlign: 'center', + }, + modalSubtitle: { + ...TYPOGRAPHY.bodyMedium, + color: COLORS.onSurfaceVariant, + marginBottom: SPACING.md, + textAlign: 'center', + }, + petList: { + marginBottom: SPACING.md, + }, + petItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: COLORS.surfaceVariant, + padding: SPACING.md, + borderRadius: LAYOUT.radiusMd, + marginBottom: SPACING.sm, + gap: SPACING.md, + }, + petItemSelected: { + backgroundColor: COLORS.primaryContainer, + borderWidth: 2, + borderColor: COLORS.primary, + }, + petItemInfo: { + flex: 1, + }, + petItemName: { + ...TYPOGRAPHY.titleMedium, + color: COLORS.onSurface, + }, + petItemNameSelected: { + color: COLORS.primary, + fontWeight: '600', + }, + petItemBreed: { + ...TYPOGRAPHY.bodySmall, + color: COLORS.onSurfaceVariant, + }, + modalActions: { + gap: SPACING.sm, + }, + modalConfirmButton: { + backgroundColor: COLORS.primary, + padding: SPACING.md, + borderRadius: LAYOUT.radiusMd, + alignItems: 'center', + }, + modalConfirmText: { + ...TYPOGRAPHY.labelLarge, + color: COLORS.onPrimary, + fontWeight: '600', + }, + modalCloseButton: { + backgroundColor: COLORS.surfaceVariant, + padding: SPACING.md, + borderRadius: LAYOUT.radiusMd, + alignItems: 'center', + }, + modalCloseText: { + ...TYPOGRAPHY.labelLarge, + color: COLORS.onSurfaceVariant, + }, +}); diff --git a/mobile/src/screens/SettingsScreen.tsx b/mobile/src/screens/SettingsScreen.tsx index 97fa13c..71b1773 100644 --- a/mobile/src/screens/SettingsScreen.tsx +++ b/mobile/src/screens/SettingsScreen.tsx @@ -2,11 +2,13 @@ import React from 'react'; import { ScrollView, View } from 'react-native'; import { Text, List, Switch, Button, Portal, Dialog } from 'react-native-paper'; import { useAuth } from '@contexts/AuthContext'; +import { useWalk } from '@contexts/WalkContext'; import { settingsStyles as styles } from '../styles/screenStyles'; import { COLORS } from '../styles/theme'; export default function SettingsScreen() { const { user, logout } = useAuth(); + const { settings, updateSettings } = useWalk(); const [notifications, setNotifications] = React.useState(true); const [darkMode, setDarkMode] = React.useState(false); const [logoutDialogVisible, setLogoutDialogVisible] = React.useState(false); @@ -62,6 +64,36 @@ export default function SettingsScreen() { /> + + + Lenkkiasetukset + + ( + + updateSettings({ ...settings, enableSync: value }) + } + /> + )} + /> + ( + + updateSettings({ ...settings, trackSteps: value }) + } + /> + )} + /> + + Tietoa diff --git a/mobile/src/screens/WalkDetailScreen.tsx b/mobile/src/screens/WalkDetailScreen.tsx new file mode 100644 index 0000000..b72eec0 --- /dev/null +++ b/mobile/src/screens/WalkDetailScreen.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from 'react-native-maps'; +import { useRoute, useNavigation } from '@react-navigation/native'; +import { Walk } from '../types'; +import { COLORS, SPACING, TYPOGRAPHY, LAYOUT } from '../styles/theme'; + +export default function WalkDetailScreen() { + const route = useRoute(); + const navigation = useNavigation(); + const { walk } = route.params as { walk: Walk }; + const mapRef = useRef(null); + + useEffect(() => { + if (walk.coordinates.length > 0 && mapRef.current) { + // Keskitä kartta lenkin reitille + const coords = walk.coordinates.map(c => ({ + latitude: c.latitude, + longitude: c.longitude, + })); + + mapRef.current.fitToCoordinates(coords, { + edgePadding: { top: 150, right: 50, bottom: 50, left: 50 }, + animated: true, + }); + } + }, [walk]); + + const formatDuration = (seconds: number): string => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + + if (hrs > 0) { + return `${hrs}h ${mins}min`; + } + return `${mins} min`; + }; + + const formatDistance = (meters: number): string => { + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + return `${(meters / 1000).toFixed(2)} km`; + }; + + const formatDate = (timestamp: number): string => { + const date = new Date(timestamp); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + {/* Kokoruutu kartta */} + + {/* Lenkin reitti */} + {walk.coordinates.length > 1 && ( + ({ + latitude: c.latitude, + longitude: c.longitude, + }))} + strokeColor={COLORS.primary} + strokeWidth={4} + /> + )} + + {/* Aloituspiste */} + {walk.coordinates.length > 0 && ( + + )} + + {/* Lopetuspiste */} + {walk.coordinates.length > 0 && ( + + )} + + + {/* Yläosan tilastopalkki */} + + + navigation.goBack()} + > + + + + + {walk.petName} + + + + + + + {formatDistance(walk.stats.distance)} + + + + + + + {formatDuration(walk.stats.duration)} + + + + + + + {walk.stats.averageSpeed.toFixed(1)} km/h + + + + + + + {walk.stats.steps || 0} + + + + {formatDate(walk.startTime)} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + map: { + flex: 1, + }, + topBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: COLORS.primary, + paddingTop: SPACING.xl + 20, + paddingBottom: SPACING.md, + paddingHorizontal: SPACING.md, + borderBottomLeftRadius: LAYOUT.radiusLg, + borderBottomRightRadius: LAYOUT.radiusLg, + elevation: 8, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + topBarHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + backButton: { + padding: SPACING.xs, + marginRight: SPACING.sm, + }, + topBarTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.xs, + }, + topBarTitle: { + ...TYPOGRAPHY.titleLarge, + color: COLORS.onPrimary, + fontWeight: '600', + }, + statsBar: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + marginTop: SPACING.sm, + }, + statItem: { + alignItems: 'center', + gap: SPACING.xs, + }, + statValue: { + ...TYPOGRAPHY.labelMedium, + color: COLORS.onPrimary, + fontWeight: '600', + }, + statDivider: { + width: 1, + height: 30, + backgroundColor: COLORS.onPrimary, + opacity: 0.3, + }, + dateText: { + ...TYPOGRAPHY.bodySmall, + color: COLORS.onPrimary, + textAlign: 'center', + marginTop: SPACING.sm, + opacity: 0.9, + }, +}); diff --git a/mobile/src/screens/WalkHistoryScreen.tsx b/mobile/src/screens/WalkHistoryScreen.tsx new file mode 100644 index 0000000..2773f2a --- /dev/null +++ b/mobile/src/screens/WalkHistoryScreen.tsx @@ -0,0 +1,378 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + Alert, +} from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { useWalk } from '@contexts/WalkContext'; +import { Walk } from '../types'; +import { COLORS, SPACING, TYPOGRAPHY, LAYOUT } from '../styles/theme'; + +export default function WalkHistoryScreen() { + const navigation = useNavigation(); + const { walks, deleteWalk, refreshWalks } = useWalk(); + const [selectedWalk, setSelectedWalk] = useState(null); + + useEffect(() => { + refreshWalks(); + }, []); + + const formatDuration = (seconds: number): string => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + + if (hrs > 0) { + return `${hrs}h ${mins}min`; + } + return `${mins} min`; + }; + + const formatDistance = (meters: number): string => { + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + return `${(meters / 1000).toFixed(2)} km`; + }; + + const formatDate = (timestamp: number): string => { + const date = new Date(timestamp); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const handleDeleteWalk = (id: string) => { + Alert.alert( + 'Poista lenkki', + 'Haluatko varmasti poistaa tämän lenkin?', + [ + { text: 'Peruuta', style: 'cancel' }, + { + text: 'Poista', + style: 'destructive', + onPress: async () => { + await deleteWalk(id); + setSelectedWalk(null); + }, + }, + ] + ); + }; + + const renderWalkItem = ({ item }: { item: Walk }) => ( + setSelectedWalk(selectedWalk?.id === item.id ? null : item)} + activeOpacity={0.7} + > + + + + + + + {item.petName} + + + {formatDate(item.startTime)} + + + + {formatDistance(item.stats.distance)} • {formatDuration(item.stats.duration)} + + + + {!item.synced && ( + + )} + + + {selectedWalk?.id === item.id && ( + + + + + Keskinopeus + + {item.stats.averageSpeed.toFixed(1)} km/h + + + + + + Askeleet + + {item.stats.steps || 0} + + + + + + Kalorit + + {item.stats.calories || 0} + + + + + + Pisteet + + {item.coordinates.length} + + + + + + navigation.navigate('WalkDetail', { walk: item })} + activeOpacity={0.7} + > + + Näytä kartalla + + + handleDeleteWalk(item.id)} + activeOpacity={0.7} + > + + Poista + + + + )} + + ); + + const renderEmptyState = () => ( + + + Ei tallennettuja lenkkejä + + Aloita ensimmäinen lenkkisi kartta-välilehdeltä + + + ); + + return ( + + + Lenkkihistoria + + {walks.length} {walks.length === 1 ? 'lenkki' : 'lenkkiä'} + + + + b.startTime - a.startTime)} + renderItem={renderWalkItem} + keyExtractor={item => item.id} + contentContainerStyle={styles.listContent} + ListEmptyComponent={renderEmptyState} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + backgroundColor: COLORS.primary, + padding: SPACING.lg, + paddingTop: SPACING.xl, + }, + headerTitle: { + ...TYPOGRAPHY.headlineMedium, + color: COLORS.onPrimary, + fontWeight: '600', + }, + headerSubtitle: { + ...TYPOGRAPHY.bodyMedium, + color: COLORS.onPrimary, + opacity: 0.9, + marginTop: SPACING.xs, + }, + listContent: { + padding: SPACING.md, + }, + walkCard: { + backgroundColor: COLORS.surface, + borderRadius: LAYOUT.radiusMd, + padding: SPACING.md, + marginBottom: SPACING.md, + elevation: 2, + shadowColor: COLORS.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + }, + walkCardSelected: { + elevation: 4, + shadowOpacity: 0.2, + shadowRadius: 4, + }, + walkCardHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + walkCardIcon: { + width: 48, + height: 48, + borderRadius: LAYOUT.radiusMd, + backgroundColor: COLORS.primaryContainer, + justifyContent: 'center', + alignItems: 'center', + marginRight: SPACING.md, + }, + walkCardInfo: { + flex: 1, + }, + walkCardTitle: { + ...TYPOGRAPHY.titleMedium, + color: COLORS.onSurface, + fontWeight: '600', + }, + walkCardDate: { + ...TYPOGRAPHY.titleSmall, + color: COLORS.onSurface, + marginBottom: SPACING.xs, + }, + walkCardStats: { + flexDirection: 'row', + alignItems: 'center', + }, + walkCardStatText: { + ...TYPOGRAPHY.bodySmall, + color: COLORS.onSurfaceVariant, + }, + walkDetailsPanel: { + marginTop: SPACING.md, + paddingTop: SPACING.md, + borderTopWidth: 1, + borderTopColor: COLORS.outlineVariant, + }, + detailsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginBottom: SPACING.md, + }, + detailItem: { + width: '48%', + alignItems: 'center', + padding: SPACING.sm, + backgroundColor: COLORS.surfaceVariant, + borderRadius: LAYOUT.radiusSm, + marginBottom: SPACING.sm, + }, + viewMapButton: { + flexDirection: 'row', + alignItems: 'center', + padding: SPACING.sm, + paddingHorizontal: SPACING.md, + gap: SPACING.md, + }, + viewMapButtonText: { + ...TYPOGRAPHY.labelMedium, + color: COLORS.primary, + marginLeft: SPACING.xs, + }, + detailLabel: { + ...TYPOGRAPHY.labelSmall, + color: COLORS.onSurfaceVariant, + marginTop: SPACING.xs, + }, + detailValue: { + ...TYPOGRAPHY.titleMedium, + color: COLORS.onSurface, + marginTop: SPACING.xs, + }, + actionButtons: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + deleteButton: { + flexDirection: 'row', + alignItems: 'center', + padding: SPACING.sm, + paddingHorizontal: SPACING.md, + }, + deleteButtonText: { + ...TYPOGRAPHY.labelMedium, + color: COLORS.error, + marginLeft: SPACING.xs, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: SPACING.xl * 2, + paddingHorizontal: SPACING.lg, + }, + emptyStateTitle: { + ...TYPOGRAPHY.titleLarge, + color: COLORS.onSurfaceVariant, + marginTop: SPACING.lg, + textAlign: 'center', + }, + emptyStateText: { + ...TYPOGRAPHY.bodyMedium, + color: COLORS.onSurfaceVariant, + marginTop: SPACING.sm, + textAlign: 'center', + }, +}); diff --git a/mobile/src/services/locationService.ts b/mobile/src/services/locationService.ts new file mode 100644 index 0000000..c2007ca --- /dev/null +++ b/mobile/src/services/locationService.ts @@ -0,0 +1,95 @@ +import * as Location from 'expo-location'; +import { Coordinate } from '../types'; + +export const locationService = { + async requestPermissions(): Promise { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + console.error('Location permission not granted'); + return false; + } + + // Request background permission for better tracking + const backgroundStatus = await Location.requestBackgroundPermissionsAsync(); + if (backgroundStatus.status !== 'granted') { + console.warn('Background location permission not granted'); + } + + return true; + } catch (error) { + console.error('Error requesting location permissions:', error); + return false; + } + }, + + async getCurrentLocation(): Promise { + try { + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + }); + + return { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + altitude: location.coords.altitude || undefined, + timestamp: location.timestamp, + }; + } catch (error) { + console.error('Error getting current location:', error); + return null; + } + }, + + async startLocationTracking( + callback: (coordinate: Coordinate) => void, + distanceInterval: number = 10 // meters + ): Promise { + try { + const subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.BestForNavigation, + timeInterval: 1000, // Update every second + distanceInterval, // Update every X meters + }, + (location) => { + callback({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + altitude: location.coords.altitude || undefined, + timestamp: location.timestamp, + }); + } + ); + + return subscription; + } catch (error) { + console.error('Error starting location tracking:', error); + return null; + } + }, + + calculateDistance(coord1: Coordinate, coord2: Coordinate): number { + // Haversine formula + const R = 6371e3; // Earth's radius in meters + const φ1 = (coord1.latitude * Math.PI) / 180; + const φ2 = (coord2.latitude * Math.PI) / 180; + const Δφ = ((coord2.latitude - coord1.latitude) * Math.PI) / 180; + const Δλ = ((coord2.longitude - coord1.longitude) * 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 + }, + + calculateTotalDistance(coordinates: Coordinate[]): number { + let totalDistance = 0; + for (let i = 1; i < coordinates.length; i++) { + totalDistance += this.calculateDistance(coordinates[i - 1], coordinates[i]); + } + return totalDistance; + }, +}; diff --git a/mobile/src/services/storageService.ts b/mobile/src/services/storageService.ts new file mode 100644 index 0000000..fee0d7a --- /dev/null +++ b/mobile/src/services/storageService.ts @@ -0,0 +1,111 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Walk, WalkSettings } from '../types'; + +const WALKS_KEY = '@walks'; +const WALK_SETTINGS_KEY = '@walk_settings'; + +export const storageService = { + // Walk operations + async saveWalk(walk: Walk): Promise { + try { + const walks = await this.getWalks(); + walks.push(walk); + await AsyncStorage.setItem(WALKS_KEY, JSON.stringify(walks)); + } catch (error) { + console.error('Error saving walk:', error); + throw error; + } + }, + + async getWalks(): Promise { + try { + const walksJson = await AsyncStorage.getItem(WALKS_KEY); + return walksJson ? JSON.parse(walksJson) : []; + } catch (error) { + console.error('Error getting walks:', error); + return []; + } + }, + + async getWalkById(id: string): Promise { + try { + const walks = await this.getWalks(); + return walks.find(walk => walk.id === id) || null; + } catch (error) { + console.error('Error getting walk by id:', error); + return null; + } + }, + + async updateWalk(id: string, updatedWalk: Walk): Promise { + try { + const walks = await this.getWalks(); + const index = walks.findIndex(walk => walk.id === id); + if (index !== -1) { + walks[index] = updatedWalk; + await AsyncStorage.setItem(WALKS_KEY, JSON.stringify(walks)); + } + } catch (error) { + console.error('Error updating walk:', error); + throw error; + } + }, + + async deleteWalk(id: string): Promise { + try { + const walks = await this.getWalks(); + const filteredWalks = walks.filter(walk => walk.id !== id); + await AsyncStorage.setItem(WALKS_KEY, JSON.stringify(filteredWalks)); + } catch (error) { + console.error('Error deleting walk:', error); + throw error; + } + }, + + async getUnsyncedWalks(): Promise { + try { + const walks = await this.getWalks(); + return walks.filter(walk => !walk.synced); + } catch (error) { + console.error('Error getting unsynced walks:', error); + return []; + } + }, + + // Settings operations + async getWalkSettings(): Promise { + try { + const settingsJson = await AsyncStorage.getItem(WALK_SETTINGS_KEY); + return settingsJson ? JSON.parse(settingsJson) : { + enableSync: false, + autoStartOnMovement: false, + trackSteps: true, + }; + } catch (error) { + console.error('Error getting walk settings:', error); + return { + enableSync: false, + autoStartOnMovement: false, + trackSteps: true, + }; + } + }, + + async saveWalkSettings(settings: WalkSettings): Promise { + try { + await AsyncStorage.setItem(WALK_SETTINGS_KEY, JSON.stringify(settings)); + } catch (error) { + console.error('Error saving walk settings:', error); + throw error; + } + }, + + async clearAllWalks(): Promise { + try { + await AsyncStorage.removeItem(WALKS_KEY); + } catch (error) { + console.error('Error clearing walks:', error); + throw error; + } + }, +}; diff --git a/mobile/src/styles/theme.ts b/mobile/src/styles/theme.ts index c3aa528..dcfee10 100644 --- a/mobile/src/styles/theme.ts +++ b/mobile/src/styles/theme.ts @@ -198,6 +198,12 @@ export const LAYOUT = { iconMd: 24, iconLg: 32, iconXl: 48, + + // Border radius + radiusSm: BORDER_RADIUS.small, + radiusMd: BORDER_RADIUS.medium, + radiusLg: BORDER_RADIUS.large, + radiusXl: BORDER_RADIUS.extraLarge, }; // ============================================ diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts index dd3f3ba..7573a81 100644 --- a/mobile/src/types/index.ts +++ b/mobile/src/types/index.ts @@ -53,3 +53,36 @@ export interface HealthRecord { date: string; notes?: string; } + +// Walk tracking types +export interface Coordinate { + latitude: number; + longitude: number; + altitude?: number; + timestamp: number; +} + +export interface WalkStats { + distance: number; // meters + duration: number; // seconds + averageSpeed: number; // km/h + steps?: number; + calories?: number; +} + +export interface Walk { + id: string; + startTime: number; + endTime: number; + coordinates: Coordinate[]; + stats: WalkStats; + petId: string; + petName: string; + synced: boolean; +} + +export interface WalkSettings { + enableSync: boolean; + autoStartOnMovement: boolean; + trackSteps: boolean; +} diff --git a/mobile/src/utils/constants.ts b/mobile/src/utils/constants.ts index 5ea0e67..5001005 100644 --- a/mobile/src/utils/constants.ts +++ b/mobile/src/utils/constants.ts @@ -15,5 +15,5 @@ export const COLORS = { }; // API -export const API_BASE_URL = 'http://77.42.94.212'; +export const API_BASE_URL = 'https://mypet.zroot.it'; export const API_TIMEOUT = 30000; diff --git a/package-lock.json b/package-lock.json index 63186f6..38da06f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,16 +15,19 @@ "name": "mypet-mobile", "version": "1.0.0", "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.10.0", "@react-navigation/native": "^7.1.0", "@react-navigation/native-stack": "^7.10.0", "axios": "^1.7.9", - "expo": "~54.0.0", + "expo": "^54.0.32", + "expo-location": "^19.0.8", + "expo-sensors": "^15.0.8", "expo-status-bar": "~3.0.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "^1.20.1", "react-native-paper": "^5.12.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", @@ -3569,9 +3572,9 @@ } }, "node_modules/@expo/cli": { - "version": "54.0.21", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.21.tgz", - "integrity": "sha512-L/FdpyZDsg/Nq6xW6kfiyF9DUzKfLZCKFXEVZcDqCNar6bXxQVotQyvgexRvtUF5nLinuT/UafLOdC3FUALUmA==", + "version": "54.0.22", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.22.tgz", + "integrity": "sha512-BTH2FCczhJLfj1cpfcKrzhKnvRLTOztgW4bVloKDqH+G3ZSohWLRFNAIz56XtdjPxBbi2/qWhGBAkl7kBon/Jw==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", @@ -3583,9 +3586,9 @@ "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.13", + "@expo/metro-config": "~54.0.14", "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.9", + "@expo/package-manager": "^1.9.10", "@expo/plist": "^0.4.8", "@expo/prebuild-config": "^54.0.8", "@expo/schema-utils": "^0.1.8", @@ -3917,9 +3920,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.13", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.13.tgz", - "integrity": "sha512-RRufMCgLR2Za1WGsh02OatIJo5qZFt31yCnIOSfoubNc3Qqe92Z41pVsbrFnmw5CIaisv1NgdBy05DHe7pEyuw==", + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", + "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4930,6 +4933,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5468,9 +5477,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.9.tgz", - "integrity": "sha512-8J6hRdgEC2eJobjoft6mKJ294cLxmi3khCUy2JJQp4htOYYkllSLUq6vudWJkTJiIuGdVR4bR6xuz2EvJLWHNg==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", + "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -6818,26 +6827,26 @@ "license": "MIT" }, "node_modules/expo": { - "version": "54.0.31", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.31.tgz", - "integrity": "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg==", + "version": "54.0.32", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.32.tgz", + "integrity": "sha512-yL9eTxiQ/QKKggVDAWO5CLjUl6IS0lPYgEvC3QM4q4fxd6rs7ks3DnbXSGVU3KNFoY/7cRNYihvd0LKYP+MCXA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.21", + "@expo/cli": "54.0.22", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.13", + "@expo/metro-config": "54.0.14", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.9", + "babel-preset-expo": "~54.0.10", "expo-asset": "~12.0.12", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.21", - "expo-font": "~14.0.10", + "expo-font": "~14.0.11", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.24", "expo-modules-core": "3.0.29", @@ -6909,9 +6918,9 @@ } }, "node_modules/expo-font": { - "version": "14.0.10", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", - "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", + "version": "14.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", + "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -6932,6 +6941,15 @@ "react": "*" } }, + "node_modules/expo-location": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", + "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -6961,6 +6979,19 @@ "react-native": "*" } }, + "node_modules/expo-sensors": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-15.0.8.tgz", + "integrity": "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -8597,9 +8628,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -8612,23 +8643,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], @@ -8646,9 +8677,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], @@ -8666,9 +8697,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], @@ -8686,9 +8717,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], @@ -8706,9 +8737,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], @@ -8726,9 +8757,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], @@ -8746,9 +8777,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], @@ -8766,9 +8797,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], @@ -8786,9 +8817,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], @@ -8806,9 +8837,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], @@ -8826,9 +8857,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], @@ -9434,6 +9465,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9695,6 +9735,18 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -10415,6 +10467,28 @@ "react-native": "*" } }, + "node_modules/react-native-maps": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz", + "integrity": "sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">= 17.0.1", + "react-native": ">= 0.64.3", + "react-native-web": ">= 0.11" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", @@ -10764,27 +10838,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11645,6 +11698,7 @@ "version": "7.5.6", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0",