Refactor mobile UI and centralize HAFAS parsing
Introduce useColors hook to replace direct theme usage in mobile screens. Extract EventHeader, JourneyList, BikeSection, and NearbyStops components to reduce complexity in EventDetailScreen. Move parseHafasJourneys to packages/core for shared usage between web and mobile clients. Update web API route with stricter HAFAS validation and consistent client instantiation. Add comprehensive codebase function guide documenting data flow, shared packages, and service integrations.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { BikeRoute, Station } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
bikeRoute: BikeRoute | null;
|
||||
loading: boolean;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<Text style={[styles.mapText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
label: { fontSize: 15, fontWeight: '500' },
|
||||
value: { fontSize: 15, fontWeight: '600' },
|
||||
mapPlaceholder: {
|
||||
marginTop: 10,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
mapText: { fontSize: 14 },
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { Event } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
event: Event;
|
||||
leaveByTime: Date | null;
|
||||
arrivalBufferMinutes: number;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
|
||||
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.time, { color: colors.accent }]}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||
|
||||
<View style={[styles.infoGrid, { borderTopColor: colors.border }]}>
|
||||
<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 }]}>
|
||||
{arriveByTime.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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20, marginBottom: 12 },
|
||||
title: { fontSize: 22, fontWeight: '700' },
|
||||
destination: { fontSize: 16, marginTop: 4 },
|
||||
time: { fontSize: 14, marginTop: 8 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
infoBox: { alignItems: 'center' },
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
journeys: Journey[];
|
||||
destStationLoading: boolean;
|
||||
walkRoute: WalkRoute | null;
|
||||
loadingWalk: boolean;
|
||||
showWalkingOption: boolean;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function JourneyList({
|
||||
journeys,
|
||||
destStationLoading,
|
||||
walkRoute,
|
||||
loadingWalk,
|
||||
showWalkingOption,
|
||||
origin,
|
||||
colors,
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||
|
||||
{destStationLoading && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Ziel-Station wird aufgelöst…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{journeys.length === 0 && !destStationLoading ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.line, { 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.detail, { color: colors.text }]}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={[styles.detail, { 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>
|
||||
))
|
||||
)}
|
||||
|
||||
{showWalkingOption && walkRoute && (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border, marginTop: 10 }]}>
|
||||
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Fußweg wird geladen…</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
line: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
detail: { fontSize: 13, marginTop: 6 },
|
||||
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import type { WienerLinienStop } from '@timetoleave/core';
|
||||
import type { DepartureRow } from '../hooks/useWienerLinien';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
|
||||
if (!loading && stops.length === 0 && !error) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Haltestellen werden geladen…</Text>
|
||||
</View>
|
||||
) : departures.length > 0 ? (
|
||||
departures.slice(0, 8).map((dep, i) => (
|
||||
<View
|
||||
key={`${dep.stopId}-${dep.lineName}-${i}`}
|
||||
style={[styles.depCard, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.depRow}>
|
||||
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
|
||||
</View>
|
||||
<Text style={[styles.direction, { color: colors.text }]} numberOfLines={1}>
|
||||
{dep.direction}
|
||||
</Text>
|
||||
<Text style={[styles.minutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
|
||||
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
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>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.hint, { color: colors.subtext }]}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 14 },
|
||||
depCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
|
||||
depRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' },
|
||||
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
direction: { flex: 1, fontSize: 13 },
|
||||
minutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
const DARK = {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
warning: '#ff9f0a',
|
||||
purple: '#B23CFF',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(28,28,30,0.95)',
|
||||
highlight: '#1a3a5c',
|
||||
} as const;
|
||||
|
||||
const LIGHT = {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
warning: '#FF9500',
|
||||
purple: '#8B5CF6',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(255,255,255,0.95)',
|
||||
highlight: '#e8f4fd',
|
||||
} as const;
|
||||
|
||||
export type AppColors = Record<keyof typeof DARK, string>;
|
||||
|
||||
export function useColors(): AppColors {
|
||||
const { dark } = useTheme();
|
||||
return dark ? DARK : LIGHT;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface DepartureRow {
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
direction: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
@@ -19,7 +19,7 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
@@ -27,24 +27,6 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
|
||||
// If editing an existing event, populate the form
|
||||
useEffect(() => {
|
||||
if (!route.params?.editEventId) return;
|
||||
@@ -162,7 +144,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
</View>
|
||||
|
||||
{success && (
|
||||
<View style={[styles.successOverlay, { backgroundColor: dark ? 'rgba(28,28,30,0.95)' : 'rgba(255,255,255,0.95)' }]}>
|
||||
<View style={[styles.successOverlay, { backgroundColor: colors.overlay }]}>
|
||||
<View style={styles.successContent}>
|
||||
<View style={styles.successCircle}>
|
||||
<Text style={styles.checkmark}>✓</Text>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fetchNativeEvents } from '../services/calendar';
|
||||
import { addEvent, loadEvents } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||
@@ -23,34 +23,12 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
purple: '#B23CFF',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
purple: '#8B5CF6',
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte ICS-URL eingeben');
|
||||
@@ -62,19 +40,26 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
const events = await api.fetchCalendar(url.trim());
|
||||
// Add imported events to local store
|
||||
const [events, existing] = await Promise.all([
|
||||
api.fetchCalendar(url.trim()),
|
||||
loadEvents(),
|
||||
]);
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
let added = 0;
|
||||
for (const evt of events) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
if (!existingIds.has(evt.id)) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
setCount(events.length);
|
||||
setCount(added);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
|
||||
@@ -11,14 +11,17 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
|
||||
import { api } from '../services/api';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
|
||||
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||
import { useGeocode } from '../hooks/useGeocode';
|
||||
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { useWienerLinien } from '../hooks/useWienerLinien';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { EventHeader } from '../components/EventHeader';
|
||||
import { JourneyList } from '../components/JourneyList';
|
||||
import { BikeSection } from '../components/BikeSection';
|
||||
import { NearbyStops } from '../components/NearbyStops';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
|
||||
type ScreenProps = {
|
||||
@@ -30,7 +33,7 @@ type TransportMode = 'train' | 'bike';
|
||||
|
||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const { eventId } = route.params;
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
@@ -46,21 +49,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
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 () => {
|
||||
@@ -78,35 +74,24 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
setShowWalkingOption(settings.showWalkingOption);
|
||||
|
||||
const found = events.find((e) => e.id === eventId);
|
||||
if (!found) {
|
||||
setError('Termin nicht gefunden');
|
||||
return;
|
||||
}
|
||||
if (!found) { setError('Termin nicht gefunden'); return; }
|
||||
setEvent(found);
|
||||
|
||||
if (originStation) {
|
||||
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
|
||||
const destExtId = destStation.station?.extId;
|
||||
if (destExtId) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
destExtId,
|
||||
found.eventTime,
|
||||
);
|
||||
const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime);
|
||||
setJourneys(results);
|
||||
} else if (destStation.error) {
|
||||
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||
}
|
||||
|
||||
// Fetch bike route if we have coordinates
|
||||
try {
|
||||
setLoadingBike(true);
|
||||
if (destCoords.coords && originStation.lat && originStation.lng) {
|
||||
const bike = await api.getBikeRoute(
|
||||
originStation.lat,
|
||||
originStation.lng,
|
||||
destCoords.coords.lat,
|
||||
destCoords.coords.lng,
|
||||
originStation.lat, originStation.lng,
|
||||
destCoords.coords.lat, destCoords.coords.lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
}
|
||||
@@ -125,7 +110,6 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Sync walk route from hook
|
||||
useEffect(() => {
|
||||
setWalkRoute(walkHook.walkRoute);
|
||||
setLoadingWalk(walkHook.loading);
|
||||
@@ -138,36 +122,17 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
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),
|
||||
activeMode === 'train' && journeys.length > 0
|
||||
? 'train'
|
||||
: activeMode === 'bike' && bikeRoute
|
||||
? 'bike'
|
||||
: null,
|
||||
arrivalBufferMinutes,
|
||||
);
|
||||
const leaveByTime = departureInfo.departureTime;
|
||||
|
||||
// Theme-based colors
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
warning: '#ff9f0a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
warning: '#FF9500',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -180,75 +145,38 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Disable bike mode if setting is off
|
||||
const bikeDisabled = !showBikeOption;
|
||||
const requestedMode: TransportMode = activeMode;
|
||||
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
|
||||
const effectiveMode: TransportMode =
|
||||
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{/* Event header */}
|
||||
{event && (
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.eventTime, { color: colors.accent }]}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</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>
|
||||
<EventHeader
|
||||
event={event}
|
||||
leaveByTime={departureInfo.departureTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
<Text style={styles.bannerText}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
<Text style={styles.bannerText}>
|
||||
Keine Ursprungstation festgelegt.{' '}
|
||||
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Transport mode selector */}
|
||||
{origin && (
|
||||
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
@@ -289,159 +217,52 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'}
|
||||
{bikeDisabled
|
||||
? 'In Einstellungen deaktiviert'
|
||||
: loadingBike
|
||||
? 'Route wird berechnet...'
|
||||
: 'Direktweg'}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
Fußweg wird geladen…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
destStationLoading={destStation.loading}
|
||||
walkRoute={walkRoute}
|
||||
loadingWalk={loadingWalk}
|
||||
showWalkingOption={showWalkingOption}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<BikeSection
|
||||
bikeRoute={bikeRoute}
|
||||
loading={loadingBike}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* WienerLinien nearby stops + departures */}
|
||||
{(wienerLinien.loading || 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.departures.length > 0 ? (
|
||||
wienerLinien.departures.slice(0, 8).map((dep, i) => (
|
||||
<View
|
||||
key={`${dep.stopId}-${dep.lineName}-${i}`}
|
||||
style={[styles.departureCard, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.departureRow}>
|
||||
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
|
||||
</View>
|
||||
<Text style={[styles.departureDirection, { color: colors.text }]} numberOfLines={1}>
|
||||
{dep.direction}
|
||||
</Text>
|
||||
<Text style={[styles.departureMinutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
|
||||
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
|
||||
</Text>
|
||||
</View>
|
||||
</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>
|
||||
)}
|
||||
<NearbyStops
|
||||
stops={wienerLinien.stops}
|
||||
departures={wienerLinien.departures}
|
||||
loading={wienerLinien.loading}
|
||||
error={wienerLinien.error}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
|
||||
<TouchableOpacity
|
||||
style={[styles.refreshBtn, { backgroundColor: colors.border }]}
|
||||
onPress={handleRefresh}
|
||||
>
|
||||
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom padding for scroll */}
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -451,57 +272,24 @@ const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loadingText: { marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700' },
|
||||
eventDest: { fontSize: 16, marginTop: 4 },
|
||||
eventTime: { fontSize: 14, marginTop: 8 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
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 },
|
||||
warningBanner: { padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 },
|
||||
bannerText: { color: '#fff', fontSize: 14 },
|
||||
bannerLink: { 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 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
emptyText: { fontSize: 14 },
|
||||
journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, marginTop: 6 },
|
||||
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 },
|
||||
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, fontWeight: '600' },
|
||||
mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 },
|
||||
mapPlaceholderText: { fontSize: 14 },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
|
||||
departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 },
|
||||
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const },
|
||||
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
departureDirection: { flex: 1, fontSize: 13 },
|
||||
departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const },
|
||||
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
refreshBtnText: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
@@ -22,28 +22,12 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
// Force countdown recalculation periodically
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
delete: '#FF3B30',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
delete: '#FF3B30',
|
||||
};
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
setEvents(list);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot
|
||||
import { api } from '../services/api';
|
||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
type ScreenProps = {
|
||||
@@ -25,6 +26,7 @@ type ScreenProps = {
|
||||
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const { dark, toggle: toggleTheme } = useTheme();
|
||||
const colors = useColors();
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Station[]>([]);
|
||||
@@ -41,24 +43,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
success: '#30d158',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
success: '#34C759',
|
||||
};
|
||||
|
||||
// Load persisted data on mount
|
||||
useEffect(() => {
|
||||
loadOriginStation().then(setOrigin);
|
||||
@@ -266,7 +250,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}>
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: colors.highlight }]} onPress={useCurrentLocation}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>
|
||||
@@ -298,7 +282,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}>
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: colors.highlight }]} onPress={toggleAdvanced}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>
|
||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
|
||||
// ── Keys ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,38 +48,14 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
|
||||
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
|
||||
const REMINDERS_MIN = [30, 10, 0];
|
||||
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
for (const minutesBefore of REMINDERS_MIN) {
|
||||
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 timestamp (seconds) as trigger — more reliable than Date object across versions
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
if (triggerTime <= now || triggerTime < twoHoursBefore) continue;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
@@ -88,12 +65,25 @@ async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
for (const notif of existing.filter(n => n.content.data?.eventId === event.id)) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Events
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -176,48 +166,12 @@ export async function saveNotificationSettings(
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function rescheduleAllNotifications(): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const settings = await loadNotificationSettings();
|
||||
|
||||
// Cancel ALL existing notifications first
|
||||
const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
if (settings.enabled) {
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
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 timestamp (seconds) as trigger — more reliable than Date object
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,20 @@ const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
|
||||
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
|
||||
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
|
||||
|
||||
/**
|
||||
* Maximum number of characters allowed in the JSON body of a HAFAS POST.
|
||||
* Keeps the relay surface small — a TripSearch + LocMatch request is ~1 KB.
|
||||
*/
|
||||
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
|
||||
|
||||
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
|
||||
type HafasMethod = (typeof ALLOWED_METHODS)[number];
|
||||
|
||||
interface HafasServiceRequest {
|
||||
meth: string;
|
||||
req?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface HafasBody extends Record<string, unknown> {
|
||||
svcReqL: HafasServiceRequest[];
|
||||
}
|
||||
|
||||
function injectHafasAuth(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
@@ -118,52 +126,30 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate body shape
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
"svcReqL" in body
|
||||
? !Array.isArray((body as Record<string, unknown>).svcReqL)
|
||||
: false
|
||||
) {
|
||||
// If svcReqL is missing, the injectHafasAuth will add an empty object —
|
||||
// so we need to check if the enriched body has it
|
||||
}
|
||||
|
||||
// Inject required HAFAS protocol fields
|
||||
const hafasBody = injectHafasAuth(body as Record<string, unknown>);
|
||||
const hafasBody = injectHafasAuth(body as Record<string, unknown>) as HafasBody;
|
||||
|
||||
// Validate enriched body
|
||||
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const svcReq = hafasBody.svcReqL[0];
|
||||
const allowedMethods = ["TripSearch", "LocMatch"];
|
||||
const svcReq = hafasBody.svcReqL[0] as HafasServiceRequest;
|
||||
|
||||
if (
|
||||
!svcReq ||
|
||||
typeof svcReq !== "object" ||
|
||||
typeof (svcReq as Record<string, unknown>).meth !== "string" ||
|
||||
!allowedMethods.includes((svcReq as Record<string, unknown>).meth as string)
|
||||
typeof svcReq.meth !== "string" ||
|
||||
!(ALLOWED_METHODS as readonly string[]).includes(svcReq.meth)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
|
||||
{ error: `Invalid HAFAS method. Allowed: ${ALLOWED_METHODS.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Cap TripSearch results at 10
|
||||
if (
|
||||
(svcReq as Record<string, unknown>).meth === "TripSearch" &&
|
||||
(svcReq as Record<string, unknown>).req &&
|
||||
typeof (svcReq as Record<string, unknown>).req === "object" &&
|
||||
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF
|
||||
) {
|
||||
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF = Math.min(
|
||||
Number(((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF),
|
||||
10,
|
||||
);
|
||||
if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") {
|
||||
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 10);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { CalendarEvent } from "@timetoleave/core";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
const client = new ApiClient();
|
||||
import { api as client } from "@/lib/api";
|
||||
|
||||
export function useCalendar(days: number = 14) {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Station } from "@timetoleave/core";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
const client = new ApiClient();
|
||||
import { api as client } from "@/lib/api";
|
||||
|
||||
interface HafasLocation {
|
||||
type: string;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Journey } from "@timetoleave/core";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
import { parseHafasJourneys } from "@/lib/hafas-client";
|
||||
import { hafasDateTime } from "@timetoleave/core";
|
||||
|
||||
const client = new ApiClient();
|
||||
import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core";
|
||||
import { api as client } from "@/lib/api";
|
||||
|
||||
export function useJourneys(
|
||||
fromStationExtId: string | null,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
export const api = new ApiClient();
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Journey, Station } from "@timetoleave/core";
|
||||
import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
|
||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
|
||||
import { parseHafasTime, hafasDateTime } from "@timetoleave/core";
|
||||
import { ApiClient } from "./api-service";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
@@ -46,57 +46,6 @@ interface HafasTripResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Shared HAFAS journey parser
|
||||
// -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a raw HAFAS response into Journey[].
|
||||
* Works with both typed and untyped (raw JSON) responses.
|
||||
*/
|
||||
export function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
|
||||
const data = json as any;
|
||||
const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
|
||||
|
||||
return outConL.map((con, i): Journey => {
|
||||
const first = con.secL?.[0];
|
||||
const last = con.secL?.[con.secL.length - 1];
|
||||
const dep = first?.dep;
|
||||
const arr = last?.arr;
|
||||
|
||||
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
|
||||
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
|
||||
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
|
||||
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
|
||||
|
||||
const delayMs = rD.getTime() - sD.getTime();
|
||||
const delay = Math.max(0, Math.round(delayMs / 60000));
|
||||
|
||||
const trains = (con.secL ?? [])
|
||||
.filter((s) => s.jny)
|
||||
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
|
||||
.filter(Boolean);
|
||||
|
||||
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
|
||||
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
|
||||
const platform = first?.dep?.dPlatfS ?? "";
|
||||
|
||||
return {
|
||||
id: con.ctxRecon ?? `journey-${i}`,
|
||||
sD,
|
||||
rD,
|
||||
sA,
|
||||
rA,
|
||||
delay,
|
||||
platform,
|
||||
changes,
|
||||
trains,
|
||||
cancelled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// HafasClient — Wraps the ÖBB HAFAS journey-planning API
|
||||
// -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user