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",