Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## 2025-05-14 - Interactive Card Accessibility
**Learning:** For interactive elements like swipeable cards that lack a standard 'onPress', 'accessibilityRole="button"' can be misleading. Using 'accessibilityActions' provides a clearer experience for screen reader users by exposing custom interactions (like Pin/Unpin) as menu options.
**Action:** Use 'accessibilityActions' and 'onAccessibilityAction' for non-standard gestures, and ensure a descriptive 'accessibilityLabel' summarizes the component's state.

## 2025-05-14 - Haptic Feedback in Gestures
**Learning:** Triggering haptic feedback continuously during a 'PanResponder' gesture creates a jittery experience. Using a 'useRef' toggle ensures feedback only occurs exactly once when a threshold is crossed.
**Action:** Implement a 'hasTriggeredHaptic' ref to guard haptic calls in continuous interaction loops.
8 changes: 8 additions & 0 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const it = {
notepadClearTitle: 'Cancella note',
notepadClearMsg: 'Sei sicuro di voler cancellare tutte le note?',
notepadClearConfirm: 'Cancella', notepadPlaceholder: 'Inizia a scrivere...',
notepadAccessibilityClear: 'Cancella tutte le note',
notepadAccessibilitySave: 'Salva le note attuali',
// TravelDoc
traveldocSub: 'Verifica documenti di viaggio',
traveldocLoading: 'Caricamento TravelDoc…',
Expand Down Expand Up @@ -96,6 +98,8 @@ const it = {
flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito',
flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario',
flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato',
flightActionPin: 'Fissa in alto',
flightActionUnpin: 'Rimuovi dall\'alto',
flightNotifEnabled: 'Notifiche attivate',
flightNotifPermDenied: 'Permesso negato',
flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.',
Expand Down Expand Up @@ -196,6 +200,8 @@ const en: typeof it = {
notepadClearTitle: 'Clear notes',
notepadClearMsg: 'Are you sure you want to clear all notes?',
notepadClearConfirm: 'Clear', notepadPlaceholder: 'Start writing...',
notepadAccessibilityClear: 'Clear all notes',
notepadAccessibilitySave: 'Save current notes',
// TravelDoc
traveldocSub: 'Travel document check',
traveldocLoading: 'Loading TravelDoc…',
Expand Down Expand Up @@ -252,6 +258,8 @@ const en: typeof it = {
flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Belt', flightDeparted: 'Departed',
flightLanded: 'Landed', flightEstimated: 'Estimated', flightOnTime: 'On time',
flightPinned: 'PINNED', flightPinnedLabel: 'Pinned',
flightActionPin: 'Pin to top',
flightActionUnpin: 'Unpin from top',
flightNotifEnabled: 'Notifications enabled',
flightNotifPermDenied: 'Permission denied',
flightNotifPermMsg: 'Enable notifications in phone settings to use this feature.',
Expand Down
41 changes: 37 additions & 4 deletions src/screens/FlightScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import {
View, Text, StyleSheet, ActivityIndicator, Modal,
FlatList, TouchableOpacity, RefreshControl, Image, Alert,
Animated, PanResponder, NativeModules, Platform,
type AccessibilityActionEvent,
} from 'react-native';
import * as Calendar from 'expo-calendar';
import * as Notifications from 'expo-notifications';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons';
import { useAppTheme, type ThemeColors } from '../context/ThemeContext';
Expand Down Expand Up @@ -61,40 +63,68 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN
const SWIPE_THRESHOLD = 80;

function SwipeableFlightCardComponent({
children, isPinned, onToggle,
children, isPinned, onToggle, accessibilityLabel,
}: {
children: React.ReactNode;
isPinned: boolean;
onToggle: () => void;
accessibilityLabel?: string;
}) {
const translateX = useRef(new Animated.Value(0)).current;
const onToggleRef = useRef(onToggle);
onToggleRef.current = onToggle;

const hasTriggeredHaptic = useRef(false);
const { t } = useLanguage();

const panResponder = useMemo(() => PanResponder.create({
onMoveShouldSetPanResponder: (_, g) =>
Math.abs(g.dx) > 15 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
onPanResponderMove: (_, g) => {
if (g.dx < 0) translateX.setValue(g.dx);
if (g.dx < 0) {
translateX.setValue(g.dx);
if (g.dx < -SWIPE_THRESHOLD && !hasTriggeredHaptic.current) {
Haptics.selectionAsync();
hasTriggeredHaptic.current = true;
} else if (g.dx >= -SWIPE_THRESHOLD && hasTriggeredHaptic.current) {
hasTriggeredHaptic.current = false;
}
}
},
onPanResponderRelease: (_, g) => {
if (g.dx < -SWIPE_THRESHOLD) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Animated.timing(translateX, { toValue: -SWIPE_THRESHOLD, duration: 100, useNativeDriver: true }).start(() => {
onToggleRef.current();
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start();
});
} else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start();
}
hasTriggeredHaptic.current = false;
},
onPanResponderTerminate: () => {
Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start();
hasTriggeredHaptic.current = false;
},
}), []);

const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'togglePin') {
onToggleRef.current();
}
};

return (
<View style={{ marginBottom: 10 }}>
<Animated.View style={{ transform: [{ translateX }] }} {...panResponder.panHandlers}>
<Animated.View
style={{ transform: [{ translateX }] }}
{...panResponder.panHandlers}
accessible
accessibilityLabel={accessibilityLabel}
accessibilityActions={[{ name: 'togglePin', label: isPinned ? t('flightActionUnpin') : t('flightActionPin') }]}
onAccessibilityAction={handleAccessibilityAction}
>
{children}
</Animated.View>
</View>
Expand Down Expand Up @@ -597,10 +627,13 @@ export default function FlightScreen() {
console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`);
}

const a11yLabel = `${isPinned ? t('flightPinnedLabel') + ', ' : ''}${flightNumber}, ${airline}, ${activeTab === 'arrivals' ? t('homeArrival') : t('homeDeparture')} ${t('homeToday')} ${originDest}, ${time}, ${statusText}`;

return (
<SwipeableFlightCard
isPinned={isPinned}
onToggle={() => isPinned ? unpinFlight() : pinFlight(item)}
accessibilityLabel={a11yLabel}
>
<View style={[s.card, isPinned && s.cardPinned, { marginBottom: 0 }]}>
{isPinned && <View style={s.pinBanner}><Text style={s.pinBannerText}>{t('flightPinned')}</Text></View>}
Expand Down Expand Up @@ -761,7 +794,7 @@ export default function FlightScreen() {
onPress={toggleNotifications}
activeOpacity={0.8}
accessible
accessibilityLabel={notifsEnabled ? 'Disattiva notifiche voli' : 'Attiva notifiche voli'}
accessibilityLabel={notifsEnabled ? t('flightNotifAccessDisable') : t('flightNotifAccessEnable')}
accessibilityRole="button"
>
<MaterialIcons
Expand Down
11 changes: 10 additions & 1 deletion src/screens/NotepadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,21 @@ export default function NotepadScreen() {
<Text style={s.title}>{t('notepadTitle')}</Text>
</View>
<View style={s.actions}>
<TouchableOpacity onPress={clear} style={s.iconBtn}>
<TouchableOpacity
onPress={clear}
style={s.iconBtn}
accessible
accessibilityLabel={t('notepadAccessibilityClear')}
accessibilityRole="button"
>
<MaterialIcons name="delete-outline" size={22} color="#EF4444" />
</TouchableOpacity>
<TouchableOpacity
onPress={save}
style={[s.saveBtn, saved && s.saveBtnDim]}
accessible
accessibilityLabel={t('notepadAccessibilitySave')}
accessibilityRole="button"
>
<MaterialIcons name="save" size={18} color="#fff" />
<Text style={s.saveTxt}>{saved ? t('notepadSaved') : t('notepadSave')}</Text>
Expand Down
Loading