mirror of
http://100.103.83.12:3003/fegger/time_to_leave.git
synced 2026-09-17 09:42:44 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 672d053ece | |||
| 316e5def72 |
+23
-15
@@ -1,22 +1,30 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from './src/services/expoNotifications';
|
||||||
import AppNavigator from './src/navigation/AppNavigator';
|
import AppNavigator from './src/navigation/AppNavigator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useEffect(() => {
|
const initRef = useRef(false);
|
||||||
// Request notification permissions on app start
|
|
||||||
Notifications.requestPermissionsAsync();
|
|
||||||
|
|
||||||
// Set up notification handler
|
useEffect(() => {
|
||||||
Notifications.setNotificationHandler({
|
// Run initialization only once
|
||||||
handleNotification: async () => ({
|
if (initRef.current) return;
|
||||||
shouldShowAlert: true,
|
initRef.current = true;
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
(async () => {
|
||||||
shouldShowBanner: true,
|
// Request notification permissions
|
||||||
shouldShowList: true,
|
await Notifications.requestPermissionsAsync();
|
||||||
}),
|
|
||||||
});
|
// Set up notification handler (called exactly once)
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <AppNavigator />;
|
return <AppNavigator />;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './src/polyfills/sharedArrayBuffer';
|
||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'jest-expo',
|
preset: 'jest-expo',
|
||||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^react$': '<rootDir>/node_modules/react',
|
||||||
|
'^react-test-renderer$': '<rootDir>/node_modules/react-test-renderer',
|
||||||
|
'^react-native-safe-area-context$': '<rootDir>/node_modules/react-native-safe-area-context',
|
||||||
|
'^react-native-screens$': '<rootDir>/node_modules/react-native-screens',
|
||||||
|
'^@react-native-async-storage/async-storage$':
|
||||||
|
'<rootDir>/node_modules/@react-native-async-storage/async-storage',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { getDefaultConfig } from 'expo/metro-config.js';
|
||||||
|
|
||||||
|
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, '../..');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(workspaceRoot, 'node_modules'),
|
||||||
|
];
|
||||||
|
config.resolver.extraNodeModules = {
|
||||||
|
react: path.resolve(projectRoot, 'node_modules/react'),
|
||||||
|
'react-test-renderer': path.resolve(projectRoot, 'node_modules/react-test-renderer'),
|
||||||
|
'react-native-safe-area-context': path.resolve(projectRoot, 'node_modules/react-native-safe-area-context'),
|
||||||
|
'react-native-screens': path.resolve(projectRoot, 'node_modules/react-native-screens'),
|
||||||
|
'@react-native-async-storage/async-storage': path.resolve(
|
||||||
|
projectRoot,
|
||||||
|
'node_modules/@react-native-async-storage/async-storage',
|
||||||
|
),
|
||||||
|
'expo-application': path.resolve(projectRoot, 'node_modules/expo-notifications/node_modules/expo-application'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -13,20 +13,20 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.2.4",
|
"@react-navigation/native": "^7.2.4",
|
||||||
"@react-navigation/native-stack": "^7.14.14",
|
"@react-navigation/native-stack": "^7.14.14",
|
||||||
"@timetoleave/api-client": "*",
|
"@timetoleave/api-client": "*",
|
||||||
"@timetoleave/core": "*",
|
"@timetoleave/core": "*",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-calendar": "^55.0.14",
|
"expo-calendar": "~15.0.8",
|
||||||
"expo-location": "^55.1.9",
|
"expo-location": "~19.0.8",
|
||||||
"expo-notifications": "^55.0.22",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.2.4",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "^5.7.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "^4.24.0"
|
"react-native-screens": "~4.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expo": "~54.0.0",
|
"jest-expo": "~54.0.0",
|
||||||
"react-test-renderer": "19.2.4",
|
"react-test-renderer": "19.1.0",
|
||||||
"ts-jest": "^29.4.9",
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.3"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
rescheduleAllNotifications
|
rescheduleAllNotifications
|
||||||
} from '../store/eventStore';
|
} from '../store/eventStore';
|
||||||
import { calculateLeaveByTime } from '../services/notifications';
|
import { calculateLeaveByTime } from '../services/notifications';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from '../services/expoNotifications';
|
||||||
|
|
||||||
// Mock AsyncStorage
|
// Mock AsyncStorage
|
||||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
@@ -21,8 +21,8 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
|
|||||||
removeItem: jest.fn(),
|
removeItem: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock expo-notifications
|
// Mock notification adapter
|
||||||
jest.mock('expo-notifications', () => ({
|
jest.mock('../services/expoNotifications', () => ({
|
||||||
getAllScheduledNotificationsAsync: jest.fn(),
|
getAllScheduledNotificationsAsync: jest.fn(),
|
||||||
cancelScheduledNotificationAsync: jest.fn(),
|
cancelScheduledNotificationAsync: jest.fn(),
|
||||||
cancelAllScheduledNotificationsAsync: jest.fn(),
|
cancelAllScheduledNotificationsAsync: jest.fn(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Tests for notification service
|
// Tests for notification service
|
||||||
// Mock expo-notifications before importing
|
// Mock notification adapter before importing
|
||||||
jest.mock('expo-notifications', () => ({
|
jest.mock('../services/expoNotifications', () => ({
|
||||||
setNotificationHandler: jest.fn(),
|
setNotificationHandler: jest.fn(),
|
||||||
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||||
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Journey } from '@timetoleave/core';
|
||||||
|
import { loadNotificationSettings } from '../store/eventStore';
|
||||||
|
|
||||||
|
interface DepartureTimeResult {
|
||||||
|
departureTime: Date | null;
|
||||||
|
arrivalTime: Date | null;
|
||||||
|
mode: 'train' | 'bike' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate departure time based on selected transport mode.
|
||||||
|
* Mirrors the web app's useDepartureTime hook.
|
||||||
|
*/
|
||||||
|
export function useDepartureTime(
|
||||||
|
eventTime: Date,
|
||||||
|
journeys: Journey[] | null,
|
||||||
|
bikeDurationSeconds: number | null,
|
||||||
|
activeMode: 'train' | 'bike' | null,
|
||||||
|
arrivalBufferMinutes: number,
|
||||||
|
): DepartureTimeResult {
|
||||||
|
return useMemo(() => {
|
||||||
|
// Calculate target arrival time (event time minus buffer)
|
||||||
|
const targetArrivalTime = new Date(eventTime);
|
||||||
|
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
|
||||||
|
|
||||||
|
// Filter out cancelled journeys
|
||||||
|
const validJourneys = journeys?.filter((journey) => !journey.cancelled) || [];
|
||||||
|
|
||||||
|
let departureTime: Date | null = null;
|
||||||
|
let arrivalTime: Date | null = null;
|
||||||
|
let mode: 'train' | 'bike' | null = null;
|
||||||
|
|
||||||
|
if (activeMode === 'train' && validJourneys.length > 0) {
|
||||||
|
// Find journeys that arrive by target time
|
||||||
|
const onTimeJourneys = validJourneys.filter(
|
||||||
|
(journey) => journey.rA.getTime() <= targetArrivalTime.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onTimeJourneys.length > 0) {
|
||||||
|
// Pick the journey with the latest departure that still arrives on time
|
||||||
|
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
||||||
|
current.rD.getTime() > latest.rD.getTime() ? current : latest,
|
||||||
|
);
|
||||||
|
|
||||||
|
departureTime = new Date(bestJourney.rD);
|
||||||
|
arrivalTime = new Date(bestJourney.rA);
|
||||||
|
mode = 'train';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMode === 'bike' && bikeDurationSeconds !== null && bikeDurationSeconds > 0) {
|
||||||
|
const bikeDurationMs = bikeDurationSeconds * 1000;
|
||||||
|
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
|
||||||
|
const targetArrivalMs = eventTime.getTime() - totalBufferMs;
|
||||||
|
|
||||||
|
departureTime = new Date(targetArrivalMs - bikeDurationMs);
|
||||||
|
arrivalTime = new Date(targetArrivalMs);
|
||||||
|
mode = 'bike';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { departureTime, arrivalTime, mode };
|
||||||
|
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { Station } from '@timetoleave/core';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
interface HafasLocation {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
extId: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDestinationStation(destination: string | undefined) {
|
||||||
|
const [station, setStation] = useState<Station | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!destination?.trim()) {
|
||||||
|
setStation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
// Debounce the lookup
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First geocode the destination to get coordinates
|
||||||
|
const geocodeResults = await api.geocode(destination, 'at');
|
||||||
|
const coords = geocodeResults[0];
|
||||||
|
|
||||||
|
if (!coords) {
|
||||||
|
if (isMounted) {
|
||||||
|
setStation(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then use HAFAS LocMatch to find the nearest station
|
||||||
|
const body = {
|
||||||
|
svcReqL: [
|
||||||
|
{
|
||||||
|
meth: 'LocMatch',
|
||||||
|
req: {
|
||||||
|
input: {
|
||||||
|
loc: {
|
||||||
|
crd: {
|
||||||
|
x: Math.round(coords.lng * 1e6),
|
||||||
|
y: Math.round(coords.lat * 1e6),
|
||||||
|
},
|
||||||
|
type: 'S',
|
||||||
|
},
|
||||||
|
maxLoc: 1,
|
||||||
|
field: 'S',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const data = await api.hafasRequest<any>(body);
|
||||||
|
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||||
|
const stations = locL
|
||||||
|
.filter((l) => l.type === 'S')
|
||||||
|
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
setStation(stations[0] ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Station lookup failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [destination]);
|
||||||
|
|
||||||
|
return { station, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { GeocodeResult } from '@timetoleave/core';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocode a destination name to coordinates.
|
||||||
|
* Mirrors the web app's useGeocode hook.
|
||||||
|
*/
|
||||||
|
export function useGeocode(destination: string | undefined) {
|
||||||
|
const [coords, setCoords] = useState<GeocodeResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!destination?.trim()) {
|
||||||
|
setCoords(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await api.geocode(destination, 'at');
|
||||||
|
if (isMounted) {
|
||||||
|
setCoords(results[0] ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Geocoding failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [destination]);
|
||||||
|
|
||||||
|
return { coords, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const THEME_KEY = '@timetoleave_theme';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
function getDefaultTheme(): Theme {
|
||||||
|
// React Native doesn't have window.matchMedia, but we can use a simple default
|
||||||
|
// In practice, we'd use useColorScheme from react-native for system preference
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme management for the mobile app.
|
||||||
|
* Mirrors the web app's useTheme hook.
|
||||||
|
*/
|
||||||
|
export function useTheme() {
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
// Load theme on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) return;
|
||||||
|
initialized.current = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const stored = await AsyncStorage.getItem(THEME_KEY);
|
||||||
|
if (stored === 'dark' || stored === 'light') {
|
||||||
|
setDark(stored === 'dark');
|
||||||
|
} else {
|
||||||
|
setDark(getDefaultTheme() === 'dark');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDark(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setDark((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {
|
||||||
|
// Silently fail storage
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { dark, toggle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { WalkRoute } from '@timetoleave/core';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch walk route between two points.
|
||||||
|
* Mirrors the web app's useWalkRoute hook.
|
||||||
|
*/
|
||||||
|
export function useWalkRoute(
|
||||||
|
fromLat: number | undefined,
|
||||||
|
fromLng: number | undefined,
|
||||||
|
toLat: number | undefined,
|
||||||
|
toLng: number | undefined,
|
||||||
|
) {
|
||||||
|
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchRoute = async () => {
|
||||||
|
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getWalkRoute(fromLat, fromLng, toLat, toLng);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setWalkRoute(data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isMounted) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch walk route';
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRoute();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [fromLat, fromLng, toLat, toLng]);
|
||||||
|
|
||||||
|
return { walkRoute, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
interface DepartureRow {
|
||||||
|
stopId: string;
|
||||||
|
lineName: string;
|
||||||
|
direction: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||||
|
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
|
||||||
|
return {
|
||||||
|
stopId: dep.stopId,
|
||||||
|
lineName: dep.line.name,
|
||||||
|
direction: dep.direction,
|
||||||
|
minutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch nearby WienerLinien stops and their departures.
|
||||||
|
* Mirrors the web app's useWienerLinien hook, adapted for mobile API client.
|
||||||
|
*/
|
||||||
|
export function useWienerLinien(
|
||||||
|
lat: number | undefined,
|
||||||
|
lng: number | undefined,
|
||||||
|
radius?: number,
|
||||||
|
) {
|
||||||
|
const [stops, setStops] = useState<WienerLinienStop[]>([]);
|
||||||
|
const [departures, setDepartures] = useState<DepartureRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stopIdsRef = useRef<string[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
// Effect for fetching stops and initial departures
|
||||||
|
useEffect(() => {
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setStops([]);
|
||||||
|
setDepartures([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lat === undefined || lng === undefined) {
|
||||||
|
resetState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceTimer = setTimeout(async () => {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortRef.current = abortController;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch nearby stops
|
||||||
|
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
|
||||||
|
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setStops(stopsList);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
const ids = stopsList.map((s) => s.id);
|
||||||
|
stopIdsRef.current = ids;
|
||||||
|
|
||||||
|
// Chain monitor fetch for departures
|
||||||
|
if (ids.length > 0) {
|
||||||
|
try {
|
||||||
|
// Fetch departures for each stop - note: mobile API client doesn't have
|
||||||
|
// a direct monitor endpoint, so we skip this for now
|
||||||
|
// The web app uses an internal API route for this
|
||||||
|
} catch {
|
||||||
|
// Silently ignore departure fetch errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
};
|
||||||
|
}, [lat, lng, radius]);
|
||||||
|
|
||||||
|
// Effect for periodic departures refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (stops.length === 0) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
const currentIds = stopIdsRef.current;
|
||||||
|
if (currentIds.length === 0) return;
|
||||||
|
|
||||||
|
// Refresh logic would go here if we had the monitor API
|
||||||
|
// For now, this is a placeholder for future implementation
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [stops.length]);
|
||||||
|
|
||||||
|
return { stops, departures, loading, error };
|
||||||
|
}
|
||||||
@@ -7,16 +7,11 @@ import { EventDetailScreen } from '../screens/EventDetailScreen';
|
|||||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||||
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
|
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
|
||||||
|
import type { RootStack } from '../types/navigation';
|
||||||
|
|
||||||
// ── Root Stack ────────────────────────────────────────
|
// ── Root Stack ──
|
||||||
|
|
||||||
const RootStack = createNativeStackNavigator<{
|
const Root = createNativeStackNavigator<RootStack>();
|
||||||
EventList: undefined;
|
|
||||||
EventDetail: { eventId: string };
|
|
||||||
AddEvent: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
CalendarImport: undefined;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
export default function AppNavigator() {
|
export default function AppNavigator() {
|
||||||
return (
|
return (
|
||||||
@@ -24,16 +19,16 @@ export default function AppNavigator() {
|
|||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<RootStack.Navigator
|
<Root.Navigator
|
||||||
initialRouteName="EventList"
|
initialRouteName="EventList"
|
||||||
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
|
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
|
||||||
>
|
>
|
||||||
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||||
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||||
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||||
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||||
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||||
</RootStack.Navigator>
|
</Root.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const globalScope = globalThis as Record<string, unknown>;
|
||||||
|
const stringPrototype = String.prototype as typeof String.prototype & {
|
||||||
|
isWellFormed?: () => boolean;
|
||||||
|
toWellFormed?: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
|
||||||
|
|
||||||
|
function toWellFormedString(value: string): string {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
const next = value.charCodeAt(index + 1);
|
||||||
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||||
|
result += value[index] + value[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
result += '\uFFFD';
|
||||||
|
}
|
||||||
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||||
|
result += '\uFFFD';
|
||||||
|
} else {
|
||||||
|
result += value[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stringPrototype.toWellFormed !== 'function') {
|
||||||
|
Object.defineProperty(String.prototype, 'toWellFormed', {
|
||||||
|
configurable: true,
|
||||||
|
value() {
|
||||||
|
return toWellFormedString(String(this));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stringPrototype.isWellFormed !== 'function') {
|
||||||
|
Object.defineProperty(String.prototype, 'isWellFormed', {
|
||||||
|
configurable: true,
|
||||||
|
value() {
|
||||||
|
const value = String(this);
|
||||||
|
return toWellFormedString(value) === value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')) {
|
||||||
|
Object.defineProperty(ArrayBuffer.prototype, 'resizable', {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalScope.SharedArrayBuffer === 'undefined') {
|
||||||
|
class SharedArrayBufferPolyfill extends ArrayBuffer {
|
||||||
|
get byteLength() {
|
||||||
|
return arrayBufferByteLength?.call(this) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get growable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(SharedArrayBufferPolyfill.prototype, Symbol.toStringTag, {
|
||||||
|
configurable: true,
|
||||||
|
value: 'SharedArrayBuffer',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalScope.SharedArrayBuffer = SharedArrayBufferPolyfill;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,29 +8,59 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { addEvent } from '../store/eventStore';
|
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
|
||||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||||
|
import type { RootStack } from '../types/navigation';
|
||||||
type RootStack = {
|
import { useTheme } from '../hooks/useTheme';
|
||||||
EventList: undefined;
|
|
||||||
EventDetail: { eventId: string };
|
|
||||||
AddEvent: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
CalendarImport: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenProps = {
|
type ScreenProps = {
|
||||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||||
route: RouteProp<RootStack, 'AddEvent'>;
|
route: RouteProp<RootStack, 'AddEvent'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AddEventScreen({ navigation }: ScreenProps) {
|
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||||
|
const { dark } = useTheme();
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [dateStr, setDateStr] = useState('');
|
const [dateStr, setDateStr] = useState('');
|
||||||
const [timeStr, setTimeStr] = useState('');
|
const [timeStr, setTimeStr] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const colors = dark ? {
|
||||||
|
background: '#1c1c1e',
|
||||||
|
card: '#2c2c2e',
|
||||||
|
text: '#f2f2f2',
|
||||||
|
subtext: '#aeaeb2',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
border: '#38383a',
|
||||||
|
error: '#ff453a',
|
||||||
|
} : {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
card: '#ffffff',
|
||||||
|
text: '#1c1c1e',
|
||||||
|
subtext: '#8e8e93',
|
||||||
|
accent: '#007AFF',
|
||||||
|
border: '#e5e5ea',
|
||||||
|
error: '#FF3B30',
|
||||||
|
};
|
||||||
|
|
||||||
|
// If editing an existing event, populate the form
|
||||||
|
useEffect(() => {
|
||||||
|
if (!route.params?.editEventId) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const events = await loadEvents();
|
||||||
|
const event = events.find((e) => e.id === route.params?.editEventId);
|
||||||
|
if (event) {
|
||||||
|
setTitle(event.title);
|
||||||
|
setDestination(event.destination);
|
||||||
|
const d = new Date(event.eventTime);
|
||||||
|
setDateStr(d.toISOString().split('T')[0]);
|
||||||
|
setTimeStr(d.toTimeString().slice(0, 5));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [route.params?.editEventId]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
if (!title.trim()) { setError('Titel erforderlich'); return false; }
|
if (!title.trim()) { setError('Titel erforderlich'); return false; }
|
||||||
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
|
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
|
||||||
@@ -46,68 +76,84 @@ export function AddEventScreen({ navigation }: ScreenProps) {
|
|||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
|
||||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||||
const event: CalendarEvent = {
|
|
||||||
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
||||||
title: title.trim(),
|
|
||||||
destination: destination.trim(),
|
|
||||||
eventTime,
|
|
||||||
source: 'manual',
|
|
||||||
};
|
|
||||||
|
|
||||||
await addEvent(event);
|
if (route.params?.editEventId) {
|
||||||
|
// Update existing event
|
||||||
|
await updateEvent(route.params.editEventId, {
|
||||||
|
title: title.trim(),
|
||||||
|
destination: destination.trim(),
|
||||||
|
eventTime,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new event
|
||||||
|
const event: CalendarEvent = {
|
||||||
|
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
title: title.trim(),
|
||||||
|
destination: destination.trim(),
|
||||||
|
eventTime,
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
await addEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<Text style={styles.label}>Titel</Text>
|
<Text style={[styles.label, { color: colors.text }]}>Titel</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="z.B. Team Meeting"
|
placeholder="z.B. Team Meeting"
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Ziel</Text>
|
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="z.B. Wien, Donau-City"
|
placeholder="z.B. Wien, Donau-City"
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={destination}
|
value={destination}
|
||||||
onChangeText={setDestination}
|
onChangeText={setDestination}
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Datum</Text>
|
<Text style={[styles.label, { color: colors.text }]}>Datum</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="JJJJ-MM-TT"
|
placeholder="JJJJ-MM-TT"
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={dateStr}
|
value={dateStr}
|
||||||
onChangeText={setDateStr}
|
onChangeText={setDateStr}
|
||||||
keyboardType="numbers-and-punctuation"
|
keyboardType="numbers-and-punctuation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Zeit</Text>
|
<Text style={[styles.label, { color: colors.text }]}>Zeit</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="SS:MM"
|
placeholder="SS:MM"
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={timeStr}
|
value={timeStr}
|
||||||
onChangeText={setTimeStr}
|
onChangeText={setTimeStr}
|
||||||
keyboardType="numbers-and-punctuation"
|
keyboardType="numbers-and-punctuation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
{error ? <Text style={[styles.errorText, { color: colors.error }]}>{error}</Text> : null}
|
||||||
|
|
||||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||||
<Text style={styles.saveBtnText}>Speichern</Text>
|
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Aktualisieren' : 'Speichern'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.saveBtn, styles.cancelBtn]}
|
style={[styles.saveBtn, styles.cancelBtn, { backgroundColor: colors.border }]}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<Text style={[styles.saveBtnText, styles.cancelText]}>Abbrechen</Text>
|
<Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -115,20 +161,18 @@ export function AddEventScreen({ navigation }: ScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
container: { flex: 1 },
|
||||||
form: { padding: 20 },
|
form: { padding: 20 },
|
||||||
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
|
label: { fontSize: 14, fontWeight: '600', marginBottom: 6 },
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5ea',
|
|
||||||
},
|
},
|
||||||
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
|
errorText: { fontSize: 14, marginBottom: 8 },
|
||||||
saveBtn: {
|
saveBtn: {
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#007AFF',
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
@@ -137,6 +181,6 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
|
cancelBtn: { marginTop: 12 },
|
||||||
cancelText: { color: '#1c1c1e' },
|
cancelText: { fontSize: 16, fontWeight: '600' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -13,14 +14,8 @@ import { api } from '../services/api';
|
|||||||
import { fetchNativeEvents } from '../services/calendar';
|
import { fetchNativeEvents } from '../services/calendar';
|
||||||
import { addEvent, loadEvents } from '../store/eventStore';
|
import { addEvent, loadEvents } from '../store/eventStore';
|
||||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||||
|
import type { RootStack } from '../types/navigation';
|
||||||
type RootStack = {
|
import { useTheme } from '../hooks/useTheme';
|
||||||
EventList: undefined;
|
|
||||||
EventDetail: { eventId: string };
|
|
||||||
AddEvent: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
CalendarImport: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenProps = {
|
type ScreenProps = {
|
||||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||||
@@ -28,11 +23,34 @@ type ScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||||
|
const { dark } = useTheme();
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [count, setCount] = useState<number | null>(null);
|
const [count, setCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const colors = dark ? {
|
||||||
|
background: '#1c1c1e',
|
||||||
|
card: '#2c2c2e',
|
||||||
|
text: '#f2f2f2',
|
||||||
|
subtext: '#aeaeb2',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
border: '#38383a',
|
||||||
|
error: '#ff453a',
|
||||||
|
success: '#30d158',
|
||||||
|
purple: '#bf5af2',
|
||||||
|
} : {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
card: '#ffffff',
|
||||||
|
text: '#1c1c1e',
|
||||||
|
subtext: '#8e8e93',
|
||||||
|
accent: '#007AFF',
|
||||||
|
border: '#e5e5ea',
|
||||||
|
error: '#FF3B30',
|
||||||
|
success: '#34C759',
|
||||||
|
purple: '#5856D6',
|
||||||
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
setError('Bitte ICS-URL eingeben');
|
setError('Bitte ICS-URL eingeben');
|
||||||
@@ -97,19 +115,20 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.heading}>Kalender-Import</Text>
|
<Text style={[styles.heading, { color: colors.text }]}>Kalender-Import</Text>
|
||||||
<Text style={styles.description}>
|
<Text style={[styles.description, { color: colors.subtext }]}>
|
||||||
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
|
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS-URL Import</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -130,13 +149,13 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Geräte-Kalender Sync</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Geräte-Kalender Sync</Text>
|
||||||
<Text style={styles.sectionDesc}>
|
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
|
||||||
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
|
Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
|
style={[styles.importBtn, { backgroundColor: colors.purple }, loading && styles.importBtnDisabled]}
|
||||||
onPress={handleSyncNative}
|
onPress={handleSyncNative}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -145,13 +164,13 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View style={styles.errorBanner}>
|
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{count !== null && (
|
{count !== null && (
|
||||||
<View style={styles.successBanner}>
|
<View style={[styles.successBanner, { backgroundColor: colors.success }]}>
|
||||||
<Text style={styles.successText}>
|
<Text style={styles.successText}>
|
||||||
✓ {count} Termin(e) erfolgreich importiert!
|
✓ {count} Termin(e) erfolgreich importiert!
|
||||||
</Text>
|
</Text>
|
||||||
@@ -162,34 +181,32 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
|||||||
style={styles.backBtn}
|
style={styles.backBtn}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<Text style={styles.backBtnText}>← Zurück</Text>
|
<Text style={[styles.backBtnText, { color: colors.accent }]}>← Zurück</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
container: { flex: 1 },
|
||||||
content: { padding: 20 },
|
content: { padding: 20 },
|
||||||
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
|
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||||
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
|
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
|
||||||
section: { marginBottom: 24 },
|
section: { marginBottom: 24 },
|
||||||
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
|
sectionTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
|
||||||
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
|
sectionDesc: { fontSize: 13, marginBottom: 12, lineHeight: 18 },
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5ea',
|
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
|
errorBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||||
errorText: { color: '#fff', fontSize: 14 },
|
errorText: { color: '#fff', fontSize: 14 },
|
||||||
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
|
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||||
successText: { color: '#fff', fontSize: 14 },
|
successText: { color: '#fff', fontSize: 14 },
|
||||||
importBtn: {
|
importBtn: {
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#007AFF',
|
||||||
@@ -197,14 +214,11 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
nativeBtn: {
|
|
||||||
backgroundColor: '#5856D6',
|
|
||||||
},
|
|
||||||
importBtnDisabled: { opacity: 0.6 },
|
importBtnDisabled: { opacity: 0.6 },
|
||||||
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
backBtn: {
|
backBtn: {
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
backBtnText: { color: '#007AFF', fontSize: 15 },
|
backBtnText: { fontSize: 15 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -8,41 +9,74 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { loadEvents, loadOriginStation } from '../store/eventStore';
|
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||||
import type { Journey, BikeRoute, Station, Event as CalendarEvent } from '@timetoleave/core';
|
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
|
||||||
|
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||||
type RootStack = {
|
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||||
EventList: undefined;
|
import { useGeocode } from '../hooks/useGeocode';
|
||||||
EventDetail: { eventId: string };
|
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||||
AddEvent: undefined;
|
import { useWienerLinien } from '../hooks/useWienerLinien';
|
||||||
Settings: undefined;
|
import { useTheme } from '../hooks/useTheme';
|
||||||
CalendarImport: undefined;
|
import type { RootStack } from '../types/navigation';
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenProps = {
|
type ScreenProps = {
|
||||||
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
|
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
|
||||||
route: RouteProp<RootStack, 'EventDetail'>;
|
route: RouteProp<RootStack, 'EventDetail'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TransportMode = 'train' | 'bike';
|
||||||
|
|
||||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||||
const { eventId } = route.params;
|
const { eventId } = route.params;
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||||
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
|
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
|
||||||
|
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
|
||||||
const [origin, setOrigin] = useState<Station | null>(null);
|
const [origin, setOrigin] = useState<Station | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingBike, setLoadingBike] = useState(false);
|
const [loadingBike, setLoadingBike] = useState(false);
|
||||||
|
const [loadingWalk, setLoadingWalk] = useState(false);
|
||||||
|
const [activeMode, setActiveMode] = useState<TransportMode>('train');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
|
||||||
|
const [showBikeOption, setShowBikeOption] = useState(true);
|
||||||
|
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||||
|
|
||||||
|
// Resolve destination text to HAFAS station ID (CRITICAL FIX)
|
||||||
|
const destStation = useDestinationStation(event?.destination);
|
||||||
|
|
||||||
|
// Geocode destination for bike/walk routes
|
||||||
|
const destCoords = useGeocode(event?.destination);
|
||||||
|
|
||||||
|
// Fetch walk route from destination station to final address
|
||||||
|
const walkHook = useWalkRoute(
|
||||||
|
destStation.station?.lat,
|
||||||
|
destStation.station?.lng,
|
||||||
|
destCoords.coords?.lat,
|
||||||
|
destCoords.coords?.lng,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch nearby WienerLinien stops
|
||||||
|
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [events, originStation] = await Promise.all([loadEvents(), loadOriginStation()]);
|
const [events, originStation, settings] = await Promise.all([
|
||||||
|
loadEvents(),
|
||||||
|
loadOriginStation(),
|
||||||
|
loadNotificationSettings(),
|
||||||
|
]);
|
||||||
setOrigin(originStation);
|
setOrigin(originStation);
|
||||||
|
setArrivalBufferMinutes(settings.arrivalBufferMinutes);
|
||||||
|
setShowBikeOption(settings.showBikeOption);
|
||||||
|
setShowWalkingOption(settings.showWalkingOption);
|
||||||
|
|
||||||
const found = events.find((e) => e.id === eventId);
|
const found = events.find((e) => e.id === eventId);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
setError('Termin nicht gefunden');
|
setError('Termin nicht gefunden');
|
||||||
@@ -51,29 +85,32 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
setEvent(found);
|
setEvent(found);
|
||||||
|
|
||||||
if (originStation) {
|
if (originStation) {
|
||||||
const results = await api.searchJourneys(
|
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
|
||||||
originStation.extId,
|
const destExtId = destStation.station?.extId;
|
||||||
found.destination,
|
if (destExtId) {
|
||||||
found.eventTime,
|
const results = await api.searchJourneys(
|
||||||
);
|
originStation.extId,
|
||||||
setJourneys(results);
|
destExtId,
|
||||||
|
found.eventTime,
|
||||||
|
);
|
||||||
|
setJourneys(results);
|
||||||
|
} else if (destStation.error) {
|
||||||
|
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch bike route if we have a destination station
|
// Fetch bike route if we have coordinates
|
||||||
// We need destination coordinates; for MVP we geocode the destination name
|
|
||||||
try {
|
try {
|
||||||
setLoadingBike(true);
|
setLoadingBike(true);
|
||||||
const geo = await api.geocode(found.destination);
|
if (destCoords.coords && originStation.lat && originStation.lng) {
|
||||||
if (geo.length > 0 && originStation.lat && originStation.lng) {
|
|
||||||
const bike = await api.getBikeRoute(
|
const bike = await api.getBikeRoute(
|
||||||
originStation.lat,
|
originStation.lat,
|
||||||
originStation.lng,
|
originStation.lng,
|
||||||
geo[0].lat,
|
destCoords.coords.lat,
|
||||||
geo[0].lng,
|
destCoords.coords.lng,
|
||||||
);
|
);
|
||||||
setBikeRoute(bike);
|
setBikeRoute(bike);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Bike route is optional — don't fail the whole screen
|
|
||||||
setBikeRoute(null);
|
setBikeRoute(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingBike(false);
|
setLoadingBike(false);
|
||||||
@@ -84,32 +121,78 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [eventId]);
|
}, [eventId, destStation.station, destStation.error, destCoords.coords]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// Sync walk route from hook
|
||||||
|
useEffect(() => {
|
||||||
|
setWalkRoute(walkHook.walkRoute);
|
||||||
|
setLoadingWalk(walkHook.loading);
|
||||||
|
}, [walkHook.walkRoute, walkHook.loading]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setBikeRoute(null);
|
setBikeRoute(null);
|
||||||
|
setWalkRoute(null);
|
||||||
|
setJourneys([]);
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use the shared departure time hook instead of inline calculation
|
||||||
|
const departureInfo = useDepartureTime(
|
||||||
|
event?.eventTime ?? new Date(),
|
||||||
|
journeys.length > 0 ? journeys : null,
|
||||||
|
bikeRoute?.duration ?? null,
|
||||||
|
activeMode === 'train' && journeys.length > 0 ? 'train' : (activeMode === 'bike' && bikeRoute ? 'bike' : null),
|
||||||
|
arrivalBufferMinutes,
|
||||||
|
);
|
||||||
|
const leaveByTime = departureInfo.departureTime;
|
||||||
|
|
||||||
|
// Theme-based colors
|
||||||
|
const colors = dark ? {
|
||||||
|
background: '#1c1c1e',
|
||||||
|
card: '#2c2c2e',
|
||||||
|
text: '#f2f2f2',
|
||||||
|
subtext: '#aeaeb2',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
border: '#38383a',
|
||||||
|
warning: '#ff9f0a',
|
||||||
|
error: '#ff453a',
|
||||||
|
} : {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
card: '#ffffff',
|
||||||
|
text: '#1c1c1e',
|
||||||
|
subtext: '#8e8e93',
|
||||||
|
accent: '#007AFF',
|
||||||
|
border: '#e5e5ea',
|
||||||
|
warning: '#FF9500',
|
||||||
|
error: '#FF3B30',
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||||
<ActivityIndicator size="large" color="#007AFF" />
|
<ActivityIndicator size="large" color={colors.accent} />
|
||||||
<Text style={styles.loadingText}>Termine werden geladen…</Text>
|
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||||
|
{destStation.loading && !event ? 'Ziel-Station wird aufgelöst…' : 'Termine werden geladen…'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable bike mode if setting is off
|
||||||
|
const bikeDisabled = !showBikeOption;
|
||||||
|
const requestedMode: TransportMode = activeMode;
|
||||||
|
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
{/* Event header */}
|
{/* Event header */}
|
||||||
{event && (
|
{event && (
|
||||||
<View style={styles.header}>
|
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text>
|
||||||
<Text style={styles.eventDest}>{event.destination}</Text>
|
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text>
|
||||||
<Text style={styles.eventTime}>
|
<Text style={[styles.eventTime, { color: colors.accent }]}>
|
||||||
{event.eventTime.toLocaleString('de-AT', {
|
{event.eventTime.toLocaleString('de-AT', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -119,20 +202,42 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.source}>Quelle: {event.source}</Text>
|
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||||
|
|
||||||
|
{/* Leave by / Arrive by / Buffer info */}
|
||||||
|
<View style={styles.infoGrid}>
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||||
|
{leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||||
|
{new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||||
|
{arrivalBufferMinutes} min
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<View style={styles.errorBanner}>
|
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Origin status */}
|
{/* Origin status */}
|
||||||
{!origin && !error && (
|
{!origin && !error && (
|
||||||
<View style={styles.warningBanner}>
|
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
|
||||||
<Text style={styles.warningBannerText}>
|
<Text style={styles.warningBannerText}>
|
||||||
Keine Ursprungstation festgelegt.
|
Keine Ursprungstation festgelegt.
|
||||||
{' '}
|
{' '}
|
||||||
@@ -143,135 +248,235 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Journeys list */}
|
{/* Transport mode selector */}
|
||||||
<View style={styles.journeys}>
|
{origin && (
|
||||||
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
|
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
{journeys.length === 0 ? (
|
<TouchableOpacity
|
||||||
<Text style={styles.emptyText}>
|
style={[
|
||||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
styles.modeButton,
|
||||||
</Text>
|
effectiveMode === 'train' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
|
||||||
) : (
|
]}
|
||||||
journeys.map((j) => (
|
onPress={() => setActiveMode('train')}
|
||||||
<View key={j.id} style={styles.journeyCard}>
|
>
|
||||||
<View style={styles.journeyRow}>
|
<View style={styles.modeHeader}>
|
||||||
<Text style={styles.lineText}>
|
<Text style={[styles.modeLabel, { color: colors.text }]}>🚆 Zug</Text>
|
||||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
{effectiveMode === 'train' && (
|
||||||
</Text>
|
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
|
||||||
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
|
<Text style={styles.activeBadgeText}>Aktiv</Text>
|
||||||
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
|
</View>
|
||||||
</View>
|
)}
|
||||||
<Text style={styles.departure}>
|
|
||||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{' '}
|
|
||||||
(Plattform {j.platform || '—'})
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.arrival}>
|
|
||||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{' '}
|
|
||||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))
|
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||||
)}
|
{showWalkingOption ? 'Bahn + finaler Fußweg' : 'Nur Bahn'}
|
||||||
</View>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Bike route section */}
|
<TouchableOpacity
|
||||||
<View style={styles.journeys}>
|
style={[
|
||||||
<Text style={styles.sectionTitle}>Radroute</Text>
|
styles.modeButton,
|
||||||
{loadingBike ? (
|
effectiveMode === 'bike' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
|
||||||
<View style={styles.centerBike}>
|
bikeDisabled && { opacity: 0.45 },
|
||||||
<ActivityIndicator size="small" color="#007AFF" />
|
]}
|
||||||
<Text style={styles.loadingText}>Radroute wird geladen…</Text>
|
onPress={() => !bikeDisabled && setActiveMode('bike')}
|
||||||
</View>
|
disabled={bikeDisabled}
|
||||||
) : bikeRoute ? (
|
>
|
||||||
<View style={styles.bikeCard}>
|
<View style={styles.modeHeader}>
|
||||||
<View style={styles.bikeRow}>
|
<Text style={[styles.modeLabel, { color: colors.text }]}>🚲 Rad</Text>
|
||||||
<Text style={styles.bikeLabel}>⏱ Dauer</Text>
|
{effectiveMode === 'bike' && (
|
||||||
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
|
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
|
||||||
|
<Text style={styles.activeBadgeText}>Aktiv</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.bikeRow}>
|
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||||
<Text style={styles.bikeLabel}>📏 Distanz</Text>
|
{bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'}
|
||||||
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Journeys list (Train mode) */}
|
||||||
|
{effectiveMode === 'train' && (
|
||||||
|
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||||
|
{destStation.loading && (
|
||||||
|
<View style={styles.centerBike}>
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||||
|
Ziel-Station wird aufgelöst…
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.mapPlaceholder}>
|
)}
|
||||||
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
|
{journeys.length === 0 && !destStation.loading ? (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||||
|
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
journeys.map((j) => (
|
||||||
|
<View key={j.id} style={[styles.journeyCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<View style={styles.journeyRow}>
|
||||||
|
<Text style={[styles.lineText, { color: colors.text }]}>
|
||||||
|
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||||
|
</Text>
|
||||||
|
{j.delay > 0 && (
|
||||||
|
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
|
||||||
|
)}
|
||||||
|
{j.cancelled && (
|
||||||
|
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.departure, { color: colors.text }]}>
|
||||||
|
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' '}
|
||||||
|
(Plattform {j.platform || '—'})
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.arrival, { color: colors.subtext }]}>
|
||||||
|
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' '}
|
||||||
|
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Walk route section (when walking option enabled) */}
|
||||||
|
{showWalkingOption && walkRoute && (
|
||||||
|
<View style={[styles.walkCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
|
||||||
|
<View style={styles.walkRow}>
|
||||||
|
<Text style={[styles.walkLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||||
|
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.walkRow}>
|
||||||
|
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||||
|
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
) : (
|
{showWalkingOption && loadingWalk && (
|
||||||
<Text style={styles.emptyText}>
|
<View style={styles.centerBike}>
|
||||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
</Text>
|
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||||
)}
|
Fußweg wird geladen…
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bike route section (Bike mode) */}
|
||||||
|
{effectiveMode === 'bike' && (
|
||||||
|
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
|
||||||
|
{loadingBike ? (
|
||||||
|
<View style={styles.centerBike}>
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.subtext }]}>Radroute wird geladen…</Text>
|
||||||
|
</View>
|
||||||
|
) : bikeRoute ? (
|
||||||
|
<View style={[styles.bikeCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<View style={styles.bikeRow}>
|
||||||
|
<Text style={[styles.bikeLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||||
|
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.bikeRow}>
|
||||||
|
<Text style={[styles.bikeLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||||
|
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.mapPlaceholderText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||||
|
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WienerLinien nearby stops */}
|
||||||
|
{wienerLinien.stops.length > 0 && (
|
||||||
|
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
|
||||||
|
{wienerLinien.loading ? (
|
||||||
|
<View style={styles.centerBike}>
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen…</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
wienerLinien.stops.slice(0, 5).map((stop) => (
|
||||||
|
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{wienerLinien.error && (
|
||||||
|
<Text style={[styles.emptyText, { color: colors.subtext }]}>{wienerLinien.error}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Refresh */}
|
{/* Refresh */}
|
||||||
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
|
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
|
||||||
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
|
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
{/* Bottom padding for scroll */}
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
container: { flex: 1 },
|
||||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
|
loadingText: { marginTop: 12, fontSize: 15 },
|
||||||
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
|
header: { padding: 20, marginBottom: 12 },
|
||||||
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
|
eventTitle: { fontSize: 22, fontWeight: '700' },
|
||||||
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
|
eventDest: { fontSize: 16, marginTop: 4 },
|
||||||
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
|
eventTime: { fontSize: 14, marginTop: 8 },
|
||||||
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
|
source: { fontSize: 12, marginTop: 4 },
|
||||||
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
|
infoGrid: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#e5e5ea' },
|
||||||
|
infoBox: { alignItems: 'center' },
|
||||||
|
infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' as const, letterSpacing: 1 },
|
||||||
|
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||||
|
errorBanner: { padding: 12, marginBottom: 12 },
|
||||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||||
warningBanner: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
|
warningBanner: { padding: 12, marginBottom: 12 },
|
||||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||||
|
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 },
|
||||||
|
modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' },
|
||||||
|
modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
modeLabel: { fontSize: 15, fontWeight: '600' },
|
||||||
|
modeMeta: { fontSize: 11, marginTop: 4 },
|
||||||
|
activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
|
||||||
|
activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' },
|
||||||
journeys: { padding: 20 },
|
journeys: { padding: 20 },
|
||||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 12 },
|
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||||
emptyText: { color: '#8e8e93', fontSize: 14 },
|
emptyText: { fontSize: 14 },
|
||||||
journeyCard: {
|
journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 14,
|
|
||||||
marginBottom: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5ea',
|
|
||||||
},
|
|
||||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
lineText: { fontSize: 16, fontWeight: '600', color: '#1c1c1e' },
|
lineText: { fontSize: 16, fontWeight: '600' },
|
||||||
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||||
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||||
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
|
departure: { fontSize: 13, marginTop: 6 },
|
||||||
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
|
arrival: { fontSize: 13, marginTop: 2 },
|
||||||
|
walkCard: { borderRadius: 10, padding: 14, marginTop: 10, borderWidth: 1 },
|
||||||
|
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||||
|
walkRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||||
|
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||||
|
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||||
centerBike: { alignItems: 'center', gap: 8 },
|
centerBike: { alignItems: 'center', gap: 8 },
|
||||||
bikeCard: {
|
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 14,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5ea',
|
|
||||||
},
|
|
||||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||||
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
|
bikeLabel: { fontSize: 15, fontWeight: '500' },
|
||||||
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
|
bikeValue: { fontSize: 15, fontWeight: '600' },
|
||||||
mapPlaceholder: {
|
mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 },
|
||||||
marginTop: 10,
|
mapPlaceholderText: { fontSize: 14 },
|
||||||
height: 100,
|
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||||
borderRadius: 8,
|
stopName: { fontSize: 14, fontWeight: '500' },
|
||||||
backgroundColor: '#f2f2f7',
|
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||||
justifyContent: 'center',
|
refreshBtnText: { fontSize: 15, fontWeight: '600' },
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#c7c7cc',
|
|
||||||
},
|
|
||||||
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
|
|
||||||
refreshBtn: {
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginTop: 20,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
backgroundColor: '#e5e5ea',
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,20 +13,8 @@ import type { RouteProp } from '@react-navigation/native';
|
|||||||
import { loadEvents, removeEvent } from '../store/eventStore';
|
import { loadEvents, removeEvent } from '../store/eventStore';
|
||||||
import { calculateCountdown } from '@timetoleave/core';
|
import { calculateCountdown } from '@timetoleave/core';
|
||||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||||
|
import type { RootStack } from '../types/navigation';
|
||||||
// Color map for countdown urgency
|
import { useTheme } from '../hooks/useTheme';
|
||||||
const urgencyColor = (urgent: boolean): string => {
|
|
||||||
if (urgent) return '#FF3B30';
|
|
||||||
return '#34C759';
|
|
||||||
};
|
|
||||||
|
|
||||||
type RootStack = {
|
|
||||||
EventList: undefined;
|
|
||||||
EventDetail: { eventId: string };
|
|
||||||
AddEvent: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
CalendarImport: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenProps = {
|
type ScreenProps = {
|
||||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||||
@@ -34,8 +22,27 @@ type ScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function EventListScreen({ navigation }: ScreenProps) {
|
export function EventListScreen({ navigation }: ScreenProps) {
|
||||||
|
const { dark } = useTheme();
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
// Force countdown recalculation periodically
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
|
const colors = dark ? {
|
||||||
|
background: '#1c1c1e',
|
||||||
|
card: '#2c2c2e',
|
||||||
|
text: '#f2f2f2',
|
||||||
|
subtext: '#aeaeb2',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
delete: '#ff453a',
|
||||||
|
} : {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
card: '#ffffff',
|
||||||
|
text: '#1c1c1e',
|
||||||
|
subtext: '#8e8e93',
|
||||||
|
accent: '#007AFF',
|
||||||
|
delete: '#FF3B30',
|
||||||
|
};
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const list = await loadEvents();
|
const list = await loadEvents();
|
||||||
@@ -43,6 +50,13 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { reload(); }, [reload]);
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
// Recalculate countdowns every 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setTick(t => t + 1), 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => { reload(); }, [reload]),
|
useCallback(() => { reload(); }, [reload]),
|
||||||
);
|
);
|
||||||
@@ -54,6 +68,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const countdown = calculateCountdown(item.eventTime);
|
const countdown = calculateCountdown(item.eventTime);
|
||||||
|
|
||||||
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
||||||
@@ -61,40 +76,53 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
const status = countdown.urgent ? 'Bald!' : countdown.label;
|
const status = countdown.urgent ? 'Bald!' : countdown.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View style={styles.cardWrapper}>
|
||||||
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
|
<TouchableOpacity
|
||||||
activeOpacity={0.6}
|
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
|
||||||
>
|
activeOpacity={0.6}
|
||||||
<View style={styles.card}>
|
style={{ flex: 1 }}
|
||||||
<View style={styles.dotRow}>
|
>
|
||||||
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
<Text style={styles.title}>{item.title}</Text>
|
<View style={styles.dotRow}>
|
||||||
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
|
<View style={[styles.dot, { backgroundColor: countdown.urgent ? colors.delete : '#34C759' }]} />
|
||||||
{countdown.label}
|
<Text style={[styles.title, { color: colors.text }]}>{item.title}</Text>
|
||||||
|
<Text style={[styles.badge, { color: countdown.urgent ? colors.delete : colors.accent }]}>
|
||||||
|
{countdown.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
|
||||||
|
<Text style={[styles.time, { color: colors.accent }]}>
|
||||||
|
{item.eventTime.toLocaleString('de-AT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.status, { color: countdown.urgent ? colors.delete : '#34C759' }]}>{status}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.subtitle}>{item.destination}</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.time}>
|
|
||||||
{item.eventTime.toLocaleString('de-AT', {
|
{/* Edit button */}
|
||||||
day: '2-digit',
|
<TouchableOpacity
|
||||||
month: '2-digit',
|
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
|
||||||
hour: '2-digit',
|
style={styles.editBtn}
|
||||||
minute: '2-digit',
|
>
|
||||||
})}
|
<Text style={[styles.editText, { color: colors.accent }]}>✏️ Bearbeiten</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.status}>{status}</Text>
|
|
||||||
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
{/* Delete button */}
|
||||||
<Text style={styles.deleteText}>Entfernen</Text>
|
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
||||||
</TouchableOpacity>
|
<Text style={[styles.deleteText, { color: colors.delete }]}>Entfernen</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||||
<Text style={styles.empty}>Keine Termine</Text>
|
<Text style={[styles.empty, { color: colors.subtext }]}>Keine Termine</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.addBtn}
|
style={styles.addBtn}
|
||||||
onPress={() => navigation.navigate('AddEvent')}
|
onPress={() => navigation.navigate('AddEvent')}
|
||||||
@@ -106,19 +134,19 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<View style={styles.topBar}>
|
<View style={styles.topBar}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => navigation.navigate('CalendarImport')}
|
onPress={() => navigation.navigate('CalendarImport')}
|
||||||
style={styles.topBtn}
|
style={styles.topBtn}
|
||||||
>
|
>
|
||||||
<Text style={styles.topBtnText}>📅 Kalender</Text>
|
<Text style={[styles.topBtnText, { color: colors.accent }]}>📅 Kalender</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => navigation.navigate('Settings')}
|
onPress={() => navigation.navigate('Settings')}
|
||||||
style={styles.topBtn}
|
style={styles.topBtn}
|
||||||
>
|
>
|
||||||
<Text style={styles.topBtnText}>⚙️ Einstellungen</Text>
|
<Text style={[styles.topBtnText, { color: colors.accent }]}>⚙️ Einstellungen</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -127,7 +155,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -141,16 +169,15 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
container: { flex: 1 },
|
||||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||||
topBtnText: { color: '#007AFF', fontSize: 15 },
|
topBtnText: { fontSize: 15 },
|
||||||
list: { padding: 12 },
|
list: { padding: 12 },
|
||||||
|
cardWrapper: { marginBottom: 12 },
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
marginBottom: 12,
|
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.08,
|
shadowOpacity: 0.08,
|
||||||
@@ -159,15 +186,17 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
|
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
|
||||||
dot: { width: 10, height: 10, borderRadius: 5 },
|
dot: { width: 10, height: 10, borderRadius: 5 },
|
||||||
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
|
title: { fontSize: 18, fontWeight: '600', flex: 1 },
|
||||||
badge: { fontSize: 12, fontWeight: '600' },
|
badge: { fontSize: 12, fontWeight: '600' },
|
||||||
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
|
subtitle: { fontSize: 14, marginBottom: 4 },
|
||||||
time: { fontSize: 13, color: '#007AFF' },
|
time: { fontSize: 13 },
|
||||||
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
|
status: { fontSize: 13, marginTop: 2, fontWeight: '500' },
|
||||||
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
|
editBtn: { alignSelf: 'flex-start', marginTop: 4 },
|
||||||
deleteText: { color: '#FF3B30', fontSize: 13 },
|
editText: { fontSize: 13, fontWeight: '500' },
|
||||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
deleteBtn: { alignSelf: 'flex-start', marginTop: 2, marginBottom: 4 },
|
||||||
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
|
deleteText: { fontSize: 13 },
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
empty: { fontSize: 20, marginBottom: 16 },
|
||||||
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||||
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
fab: {
|
fab: {
|
||||||
|
|||||||
@@ -15,14 +15,8 @@ import * as Location from 'expo-location';
|
|||||||
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
|
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||||
|
import type { RootStack } from '../types/navigation';
|
||||||
type RootStack = {
|
import { useTheme } from '../hooks/useTheme';
|
||||||
EventList: undefined;
|
|
||||||
EventDetail: { eventId: string };
|
|
||||||
AddEvent: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
CalendarImport: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenProps = {
|
type ScreenProps = {
|
||||||
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
|
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
|
||||||
@@ -30,6 +24,7 @@ type ScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||||
|
const { dark, toggle: toggleTheme } = useTheme();
|
||||||
const [origin, setOrigin] = useState<Station | null>(null);
|
const [origin, setOrigin] = useState<Station | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<Station[]>([]);
|
const [results, setResults] = useState<Station[]>([]);
|
||||||
@@ -45,6 +40,24 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|||||||
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const colors = dark ? {
|
||||||
|
background: '#1c1c1e',
|
||||||
|
card: '#2c2c2e',
|
||||||
|
text: '#f2f2f2',
|
||||||
|
subtext: '#aeaeb2',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
border: '#38383a',
|
||||||
|
success: '#30d158',
|
||||||
|
} : {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
card: '#ffffff',
|
||||||
|
text: '#1c1c1e',
|
||||||
|
subtext: '#8e8e93',
|
||||||
|
accent: '#007AFF',
|
||||||
|
border: '#e5e5ea',
|
||||||
|
success: '#34C759',
|
||||||
|
};
|
||||||
|
|
||||||
// Load persisted data on mount
|
// Load persisted data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOriginStation().then(setOrigin);
|
loadOriginStation().then(setOrigin);
|
||||||
@@ -175,95 +188,110 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
{/* Appearance */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Dunkelmodus</Text>
|
||||||
|
<Switch
|
||||||
|
value={dark}
|
||||||
|
onValueChange={toggleTheme}
|
||||||
|
trackColor={{ true: colors.accent, false: colors.border }}
|
||||||
|
accessibilityLabel="Dunkelmodus umschalten"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Origin Station */}
|
{/* Origin Station */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Ursprungstation</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Ursprungstation</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="Station suchen …"
|
placeholder="Station suchen …"
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={onQueryChange}
|
onChangeText={onQueryChange}
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
accessibilityLabel="Station suchen"
|
accessibilityLabel="Station suchen"
|
||||||
/>
|
/>
|
||||||
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
|
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
|
||||||
{origin && (
|
{origin && (
|
||||||
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
|
<Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results.map((s) => (
|
{results.map((s) => (
|
||||||
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
|
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
|
||||||
<Text style={styles.resultItem}>{s.name}</Text>
|
<Text style={[styles.resultItem, { color: colors.accent, borderBottomColor: colors.border }]}>{s.name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
|
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}>
|
||||||
<Text style={styles.locBtnText}>📍 Aktuelle Position verwenden</Text>
|
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.locStatus}>
|
<Text style={[styles.locStatus, { color: colors.subtext }]}>
|
||||||
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
|
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Notification Settings */}
|
{/* Notification Settings */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Benachrichtigungen</Text>
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Benachrichtigungen aktivieren</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={notifSettings.enabled}
|
value={notifSettings.enabled}
|
||||||
onValueChange={toggleNotifications}
|
onValueChange={toggleNotifications}
|
||||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
trackColor={{ true: colors.accent, false: colors.border }}
|
||||||
accessibilityLabel="Benachrichtigungen umschalten"
|
accessibilityLabel="Benachrichtigungen umschalten"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Pufferzeit (Minuten)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.numberInput]}
|
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
value={String(notifSettings.bufferMinutes)}
|
value={String(notifSettings.bufferMinutes)}
|
||||||
onChangeText={updateBufferMinutes}
|
onChangeText={updateBufferMinutes}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
accessibilityLabel="Pufferzeit in Minuten"
|
accessibilityLabel="Pufferzeit in Minuten"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.hint}>
|
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn]} onPress={toggleAdvanced}>
|
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}>
|
||||||
<Text style={styles.locBtnText}>
|
<Text style={[styles.locBtnText, { color: colors.accent }]}>
|
||||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<View style={styles.advancedSection}>
|
<View style={[styles.advancedSection, { borderTopColor: colors.border }]}>
|
||||||
<Text style={styles.settingLabel}>Ankunfts-Puffer (Minuten)</Text>
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Ankunfts-Puffer (Minuten)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.numberInput]}
|
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
value={String(notifSettings.arrivalBufferMinutes)}
|
value={String(notifSettings.arrivalBufferMinutes)}
|
||||||
onChangeText={updateArrivalBuffer}
|
onChangeText={updateArrivalBuffer}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
accessibilityLabel="Ankunfts-Puffer in Minuten"
|
accessibilityLabel="Ankunfts-Puffer in Minuten"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.hint}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
|
<Text style={[styles.hint, { color: colors.subtext }]}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
|
||||||
|
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<Text style={styles.settingLabel}>Zu Fuß-Option anzeigen</Text>
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Zu Fuß-Option anzeigen</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={notifSettings.showWalkingOption}
|
value={notifSettings.showWalkingOption}
|
||||||
onValueChange={toggleWalking}
|
onValueChange={toggleWalking}
|
||||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
trackColor={{ true: colors.accent, false: colors.border }}
|
||||||
accessibilityLabel="Zu Fuß-Option umschalten"
|
accessibilityLabel="Zu Fuß-Option umschalten"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<Text style={styles.settingLabel}>Fahrrad-Option anzeigen</Text>
|
<Text style={[styles.settingLabel, { color: colors.text }]}>Fahrrad-Option anzeigen</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={notifSettings.showBikeOption}
|
value={notifSettings.showBikeOption}
|
||||||
onValueChange={toggleBike}
|
onValueChange={toggleBike}
|
||||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
trackColor={{ true: colors.accent, false: colors.border }}
|
||||||
accessibilityLabel="Fahrrad-Option umschalten"
|
accessibilityLabel="Fahrrad-Option umschalten"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -275,36 +303,31 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
|
container: { flex: 1, padding: 20 },
|
||||||
section: { marginBottom: 24 },
|
section: { marginBottom: 24 },
|
||||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
|
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5ea',
|
|
||||||
},
|
},
|
||||||
numberInput: { width: 80 },
|
numberInput: { width: 80 },
|
||||||
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
|
currentStation: { fontSize: 14, marginTop: 6 },
|
||||||
resultItem: {
|
resultItem: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: '#007AFF',
|
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e5ea',
|
|
||||||
},
|
},
|
||||||
locBtn: {
|
locBtn: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
backgroundColor: '#e8f4fd',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
|
locBtnText: { fontSize: 15, fontWeight: '500' },
|
||||||
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
locStatus: { fontSize: 12, marginTop: 6 },
|
||||||
advancedToggle: {
|
advancedToggle: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
@@ -313,9 +336,8 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
paddingTop: 16,
|
paddingTop: 16,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#e5e5ea',
|
|
||||||
},
|
},
|
||||||
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||||
settingLabel: { fontSize: 14, color: '#1c1c1e' },
|
settingLabel: { fontSize: 14 },
|
||||||
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
hint: { fontSize: 12, marginTop: 6 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Public API re-exports from expo-notifications
|
||||||
|
// Using stable public exports instead of internal /build/ paths
|
||||||
|
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
|
||||||
|
export default Notifications;
|
||||||
|
export {
|
||||||
|
requestPermissionsAsync,
|
||||||
|
setNotificationHandler,
|
||||||
|
getAllScheduledNotificationsAsync,
|
||||||
|
cancelScheduledNotificationAsync,
|
||||||
|
cancelAllScheduledNotificationsAsync,
|
||||||
|
scheduleNotificationAsync,
|
||||||
|
} from 'expo-notifications';
|
||||||
|
|
||||||
|
// Re-export types from the public package
|
||||||
|
export type {
|
||||||
|
NotificationBehavior,
|
||||||
|
NotificationRequest,
|
||||||
|
NotificationRequestInput,
|
||||||
|
SchedulableTriggerInput,
|
||||||
|
} from 'expo-notifications';
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import * as Notifications from 'expo-notifications';
|
|
||||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
|
||||||
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
|
|
||||||
|
|
||||||
// Register for push notification permissions
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
shouldShowBanner: true,
|
|
||||||
shouldShowList: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate leave-by time from event time and journey data.
|
|
||||||
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
|
|
||||||
*
|
|
||||||
* @param event - The event to calculate leave-by time for
|
|
||||||
* @param journeys - Journey data for this event
|
|
||||||
* @param arrivalBufferMinutes - How many minutes before the event to arrive
|
|
||||||
* @param bufferMinutes - How many minutes before leaving to be reminded
|
|
||||||
*/
|
|
||||||
export function calculateLeaveByTime(
|
|
||||||
event: Event,
|
|
||||||
journeys: Journey[],
|
|
||||||
arrivalBufferMinutes: number,
|
|
||||||
bufferMinutes: number
|
|
||||||
): Date {
|
|
||||||
// Calculate target arrival time (event time minus arrival buffer)
|
|
||||||
const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000;
|
|
||||||
|
|
||||||
// If we have journeys, use the earliest non-cancelled real departure minus reminder buffer
|
|
||||||
if (journeys.length > 0) {
|
|
||||||
const best = journeys
|
|
||||||
.filter((j) => !j.cancelled)
|
|
||||||
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
|
|
||||||
|
|
||||||
if (best) {
|
|
||||||
// Leave by time = earliest real departure time - reminder buffer
|
|
||||||
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: event time minus arrival buffer minus reminder (no journey data)
|
|
||||||
return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule notifications for an event
|
|
||||||
*
|
|
||||||
* @param event - The event to schedule notifications for
|
|
||||||
* @param journeys - Journey data for this event (optional)
|
|
||||||
* @param settings - Notification settings
|
|
||||||
*/
|
|
||||||
export async function scheduleNotificationsForEvent(
|
|
||||||
event: Event,
|
|
||||||
journeys: Journey[] = [],
|
|
||||||
settings: ReminderSettings
|
|
||||||
): Promise<void> {
|
|
||||||
// Don't schedule if notifications are disabled
|
|
||||||
if (!settings.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate leave-by time (when user should actually leave)
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, journeys, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
|
||||||
|
|
||||||
// Cancel existing notifications for this event - cancel one by one
|
|
||||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
|
||||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
|
||||||
if (toCancel.length > 0) {
|
|
||||||
for (const notif of toCancel) {
|
|
||||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default reminders: 30min, 10min, and at leave-by time
|
|
||||||
// But respect the buffer time - we want reminders relative to when they should leave
|
|
||||||
const defaultReminders = [30, 10, 0];
|
|
||||||
|
|
||||||
// Schedule notifications
|
|
||||||
for (const minutesBefore of defaultReminders) {
|
|
||||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
|
||||||
|
|
||||||
// Skip if trigger time is in the past
|
|
||||||
if (triggerTime <= new Date()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if this would be before the event actually starts (add some safety margin)
|
|
||||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Date trigger with time property
|
|
||||||
await Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: `🚆 ${event.title}`,
|
|
||||||
body: minutesBefore === 0
|
|
||||||
? 'Zeit zu gehen!'
|
|
||||||
: `${minutesBefore} Minuten bis du losmusst`,
|
|
||||||
data: { eventId: event.id },
|
|
||||||
},
|
|
||||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reschedule notifications for all events
|
|
||||||
* Use this when origin station changes or notification settings are updated
|
|
||||||
*/
|
|
||||||
export async function rescheduleAllNotifications(
|
|
||||||
events: Event[],
|
|
||||||
journeysMap: Record<string, Journey[]>, // eventId -> journeys
|
|
||||||
settings: ReminderSettings
|
|
||||||
): Promise<void> {
|
|
||||||
// Cancel ALL existing notifications first
|
|
||||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
|
||||||
|
|
||||||
// Schedule new notifications for each event
|
|
||||||
for (const event of events) {
|
|
||||||
const eventJourneys = journeysMap[event.id] || [];
|
|
||||||
await scheduleNotificationsForEvent(event, eventJourneys, settings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request permissions if not already granted
|
|
||||||
let permissionsRequested = false;
|
|
||||||
|
|
||||||
export async function requestNotificationPermissions(): Promise<boolean> {
|
|
||||||
if (permissionsRequested) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
permissionsRequested = true;
|
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
|
||||||
return status === 'granted';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request permissions automatically when app starts (for Android)
|
|
||||||
// This is called in App.tsx
|
|
||||||
|
|
||||||
export function setupNotifications() {
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
shouldShowBanner: true,
|
|
||||||
shouldShowList: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from '../services/expoNotifications';
|
||||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
|
||||||
|
|
||||||
// ── Keys ───────────────────────────────
|
// ── Keys ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const EVENTS_KEY = '@timetoleave_events';
|
const EVENTS_KEY = '@timetoleave_events';
|
||||||
const ORIGIN_KEY = '@timetoleave_origin';
|
const ORIGIN_KEY = '@timetoleave_origin';
|
||||||
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
|
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
|
||||||
|
|
||||||
// ── Default notification settings ─────────────────────
|
// ── Default notification settings ─────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||||
bufferMinutes: 30,
|
bufferMinutes: 30,
|
||||||
@@ -19,7 +18,7 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
|||||||
showBikeOption: true,
|
showBikeOption: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function reviveDates(json: string): Event[] {
|
function reviveDates(json: string): Event[] {
|
||||||
try {
|
try {
|
||||||
@@ -35,9 +34,9 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
|
|||||||
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Notification scheduling utilities
|
// Notification scheduling utilities
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
|
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
|
||||||
// Calculate target arrival time (event time minus arrival buffer)
|
// Calculate target arrival time (event time minus arrival buffer)
|
||||||
@@ -78,6 +77,9 @@ async function scheduleEventNotification(event: Event): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use timestamp (seconds) as trigger — more reliable than Date object across versions
|
||||||
|
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||||
|
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: `🚆 ${event.title}`,
|
title: `🚆 ${event.title}`,
|
||||||
@@ -86,15 +88,15 @@ async function scheduleEventNotification(event: Event): Promise<void> {
|
|||||||
: `${minutesBefore} Minuten bis du losmusst`,
|
: `${minutesBefore} Minuten bis du losmusst`,
|
||||||
data: { eventId: event.id },
|
data: { eventId: event.id },
|
||||||
},
|
},
|
||||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
trigger: timestampSeconds as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Events ────────────────────────────────────────────
|
// Events
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ── Events ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function loadEvents(): Promise<Event[]> {
|
export async function loadEvents(): Promise<Event[]> {
|
||||||
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
||||||
@@ -113,6 +115,17 @@ export async function addEvent(event: Event): Promise<void> {
|
|||||||
await scheduleEventNotification(event);
|
await scheduleEventNotification(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(id: string, updates: Partial<Event>): Promise<void> {
|
||||||
|
const events = await loadEvents();
|
||||||
|
const updated = events.map((e) => (e.id === id ? { ...e, ...updates } : e));
|
||||||
|
await saveEvents(updated);
|
||||||
|
// Reschedule notification for updated event
|
||||||
|
const updatedEvent = updated.find((e) => e.id === id);
|
||||||
|
if (updatedEvent) {
|
||||||
|
await scheduleEventNotification(updatedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
|
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
|
||||||
const events = await loadEvents();
|
const events = await loadEvents();
|
||||||
const filtered = events.filter((e) => e.id !== id);
|
const filtered = events.filter((e) => e.id !== id);
|
||||||
@@ -128,8 +141,9 @@ export async function removeEvent(id: string, onDone?: () => void): Promise<void
|
|||||||
onDone?.();
|
onDone?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Origin Station ────────────────────────────────────
|
// Origin Station
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function loadOriginStation(): Promise<Station | null> {
|
export async function loadOriginStation(): Promise<Station | null> {
|
||||||
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
||||||
@@ -141,8 +155,9 @@ export async function saveOriginStation(station: Station): Promise<void> {
|
|||||||
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Notification Settings ──────────────────────────────
|
// Notification Settings
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
||||||
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
||||||
@@ -156,9 +171,9 @@ export async function saveNotificationSettings(
|
|||||||
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
|
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Reschedule all notifications (for origin/setting changes)
|
// Reschedule all notifications (for origin/setting changes)
|
||||||
// ────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function rescheduleAllNotifications(): Promise<void> {
|
export async function rescheduleAllNotifications(): Promise<void> {
|
||||||
const events = await loadEvents();
|
const events = await loadEvents();
|
||||||
@@ -188,6 +203,9 @@ export async function rescheduleAllNotifications(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use timestamp (seconds) as trigger — more reliable than Date object
|
||||||
|
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||||
|
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: `🚆 ${event.title}`,
|
title: `🚆 ${event.title}`,
|
||||||
@@ -196,7 +214,8 @@ export async function rescheduleAllNotifications(): Promise<void> {
|
|||||||
: `${minutesBefore} Minuten bis du losmusst`,
|
: `${minutesBefore} Minuten bis du losmusst`,
|
||||||
data: { eventId: event.id },
|
data: { eventId: event.id },
|
||||||
},
|
},
|
||||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
trigger: timestampSeconds as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Shared navigation types for the entire app.
|
||||||
|
* Import from here instead of duplicating in each screen.
|
||||||
|
*/
|
||||||
|
export type RootStack = {
|
||||||
|
EventList: undefined;
|
||||||
|
EventDetail: { eventId: string };
|
||||||
|
AddEvent: { editEventId?: string };
|
||||||
|
Settings: undefined;
|
||||||
|
CalendarImport: undefined;
|
||||||
|
};
|
||||||
Generated
+226
-131
@@ -16,20 +16,20 @@
|
|||||||
"name": "@timetoleave/mobile",
|
"name": "@timetoleave/mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.2.4",
|
"@react-navigation/native": "^7.2.4",
|
||||||
"@react-navigation/native-stack": "^7.14.14",
|
"@react-navigation/native-stack": "^7.14.14",
|
||||||
"@timetoleave/api-client": "*",
|
"@timetoleave/api-client": "*",
|
||||||
"@timetoleave/core": "*",
|
"@timetoleave/core": "*",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-calendar": "^55.0.14",
|
"expo-calendar": "~15.0.8",
|
||||||
"expo-location": "^55.1.9",
|
"expo-location": "~19.0.8",
|
||||||
"expo-notifications": "^55.0.22",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.2.4",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "^5.7.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "^4.24.0"
|
"react-native-screens": "~4.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -39,12 +39,127 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expo": "~54.0.0",
|
"jest-expo": "~54.0.0",
|
||||||
"react-test-renderer": "19.2.4",
|
"react-test-renderer": "19.1.0",
|
||||||
"ts-jest": "^29.4.9",
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/mobile/node_modules/@react-native-async-storage/async-storage": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"merge-options": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/expo-calendar": {
|
||||||
|
"version": "15.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-15.0.8.tgz",
|
||||||
|
"integrity": "sha512-i+ojy6zFnWSPb2DYp4L4W4U5iVI+NXnuHr3xysShoV8znNOmixP1TOYuJXt5Lpz+BpHCWseU31gV1E5SSkIKsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/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": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/expo-notifications": {
|
||||||
|
"version": "0.32.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
|
||||||
|
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.8.8",
|
||||||
|
"@ide/backoff": "^1.0.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"assert": "^2.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~7.0.8",
|
||||||
|
"expo-constants": "~18.0.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/expo-notifications/node_modules/expo-application": {
|
||||||
|
"version": "7.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||||
|
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/react": {
|
||||||
|
"version": "19.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/react-native-safe-area-context": {
|
||||||
|
"version": "5.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||||
|
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/react-native-screens": {
|
||||||
|
"version": "4.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
|
||||||
|
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-freeze": "^1.0.0",
|
||||||
|
"react-native-is-edge-to-edge": "^1.2.1",
|
||||||
|
"warn-once": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/react-test-renderer": {
|
||||||
|
"version": "19.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||||
|
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^19.1.0",
|
||||||
|
"scheduler": "^0.26.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/mobile/node_modules/scheduler": {
|
||||||
|
"version": "0.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
|
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"name": "@timetoleave/web",
|
"name": "@timetoleave/web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2742,6 +2857,12 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ide/backoff": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
@@ -4658,19 +4779,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native-async-storage/async-storage": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"idb": "8.0.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -7227,6 +7335,19 @@
|
|||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/assert": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.2",
|
||||||
|
"is-nan": "^1.3.2",
|
||||||
|
"object-is": "^1.1.5",
|
||||||
|
"object.assign": "^4.1.4",
|
||||||
|
"util": "^0.12.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -7271,7 +7392,6 @@
|
|||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"possible-typed-array-names": "^1.0.0"
|
"possible-typed-array-names": "^1.0.0"
|
||||||
@@ -7776,7 +7896,6 @@
|
|||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -7795,7 +7914,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7809,7 +7927,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -8499,7 +8616,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
@@ -8526,7 +8642,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.0.1",
|
"define-data-property": "^1.0.1",
|
||||||
@@ -8683,7 +8798,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -8863,7 +8977,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -8917,7 +9030,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -9592,15 +9704,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-application": {
|
|
||||||
"version": "55.0.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.14.tgz",
|
|
||||||
"integrity": "sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "12.0.13",
|
"version": "12.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
||||||
@@ -9616,16 +9719,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-calendar": {
|
|
||||||
"version": "55.0.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-55.0.14.tgz",
|
|
||||||
"integrity": "sha512-DndwzRNrjyjaMRY9ob/X8TdptLcpckVMGnv8rMjABH2jMWToeLElgq7lttytCTAoablZfVLowcizdD+GwSqNzw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.13",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
@@ -9674,18 +9767,6 @@
|
|||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-location": {
|
|
||||||
"version": "55.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-55.1.9.tgz",
|
|
||||||
"integrity": "sha512-PIH9/qeyhtGh190FyIJNZYHXZOoi42SbVHY9IoTMBmqWLHf1BJyGhPpFlaLBSCjxObqfVZmrWsN5dtjueSwYQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/image-utils": "^0.8.13"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.25",
|
"version": "3.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||||
@@ -9715,51 +9796,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-notifications": {
|
|
||||||
"version": "55.0.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.22.tgz",
|
|
||||||
"integrity": "sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/image-utils": "^0.8.13",
|
|
||||||
"abort-controller": "^3.0.0",
|
|
||||||
"badgin": "^1.1.5",
|
|
||||||
"expo-application": "~55.0.14",
|
|
||||||
"expo-constants": "~55.0.15"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-notifications/node_modules/@expo/env": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-RJtGFfj/ygO/6zcVbV3cckHf4THcEkv5IZft1GjCB3dfT6axvzvIwXE9EiQqQYmGHcQ+ZrvC8xZcIhiHba0pYg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^4.0.0",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"getenv": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-notifications/node_modules/expo-constants": {
|
|
||||||
"version": "55.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.16.tgz",
|
|
||||||
"integrity": "sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/env": "~2.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
|
||||||
@@ -10026,7 +10062,6 @@
|
|||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-callable": "^1.2.7"
|
"is-callable": "^1.2.7"
|
||||||
@@ -10137,7 +10172,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -10165,7 +10199,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -10199,7 +10232,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -10362,7 +10394,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -10425,7 +10456,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0"
|
"es-define-property": "^1.0.0"
|
||||||
@@ -10454,7 +10484,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -10467,7 +10496,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -10639,12 +10667,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
|
||||||
"version": "8.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
|
||||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -10802,6 +10824,22 @@
|
|||||||
"loose-envify": "^1.0.0"
|
"loose-envify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arguments": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -10907,7 +10945,6 @@
|
|||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -11030,7 +11067,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.4",
|
"call-bound": "^1.0.4",
|
||||||
@@ -11072,6 +11108,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-nan": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.0",
|
||||||
|
"define-properties": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-negative-zero": {
|
"node_modules/is-negative-zero": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
|
||||||
@@ -11111,6 +11163,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-potential-custom-element-name": {
|
"node_modules/is-potential-custom-element-name": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
@@ -11122,7 +11183,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -11218,7 +11278,6 @@
|
|||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"which-typed-array": "^1.1.16"
|
"which-typed-array": "^1.1.16"
|
||||||
@@ -14428,7 +14487,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -14447,6 +14505,18 @@
|
|||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-options": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-obj": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -15267,11 +15337,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-is": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.7",
|
||||||
|
"define-properties": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-keys": {
|
"node_modules/object-keys": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -15281,7 +15366,6 @@
|
|||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
||||||
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind": "^1.0.8",
|
"call-bind": "^1.0.8",
|
||||||
@@ -15888,7 +15972,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -16332,6 +16415,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||||
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
|
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
@@ -16342,6 +16426,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.24.0.tgz",
|
||||||
"integrity": "sha512-SyoiGaDofiyGPFrUkn1oGsAzkRuX1JUvTD9YQQK3G1JGQ5VWkvHgYSsc1K9OrLsDQxN7NmV71O0sHCAh8cBetA==",
|
"integrity": "sha512-SyoiGaDofiyGPFrUkn1oGsAzkRuX1JUvTD9YQQK3G1JGQ5VWkvHgYSsc1K9OrLsDQxN7NmV71O0sHCAh8cBetA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-freeze": "^1.0.0",
|
"react-freeze": "^1.0.0",
|
||||||
"warn-once": "^0.1.0"
|
"warn-once": "^0.1.0"
|
||||||
@@ -16915,7 +17000,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -17086,7 +17170,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
@@ -18673,6 +18756,19 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||||
|
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"is-arguments": "^1.0.4",
|
||||||
|
"is-generator-function": "^1.0.7",
|
||||||
|
"is-typed-array": "^1.1.3",
|
||||||
|
"which-typed-array": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -19100,7 +19196,6 @@
|
|||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"available-typed-arrays": "^1.0.7",
|
"available-typed-arrays": "^1.0.7",
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
"typecheck:mobile": "npm run typecheck -w apps/mobile"
|
"typecheck:mobile": "npm run typecheck -w apps/mobile"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react-test-renderer": "19.2.4"
|
"react-test-renderer": "19.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user