admin管理员组文章数量:1401845
I’m building a workout app in React Native with Expo, and I need to implement a rest timer that works even when the app is in the background or the phone is locked. When the timer completes, a notification should alert the user.
The timer should continue running in the background. It must work on both iOS and Android. When the timer ends, a notification should appear, even if the app is not active.
I am using: expo-notifications for handling notifications. expo-task-manager and expo-background-fetch for background execution. AsyncStorage to persist timer state.
const TIMER_BACKGROUND_TASK = 'WORKOUT_TIMER_BACKGROUND_TASK';
const STORAGE_KEY = '@workout_timer:data';
const TIMER_NOTIFICATION_ID = 'workout-timer-complete';
// Configurazione delle notifiche
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
priority: Notifications.AndroidNotificationPriority.MAX,
}),
});
// Setup canale notifiche per Android
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('workout-timer-complete', {
name: 'Timer Completato',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 400, 200, 400],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
sound: 'default',
enableVibrate: true,
});
}
// Formatta i secondi in MM:SS
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Definizione del task in background per il controllo del timer
TaskManager.defineTask(TIMER_BACKGROUND_TASK, async () => {
try {
const timerDataString = await AsyncStorage.getItem(STORAGE_KEY);
if (!timerDataString) {
return BackgroundFetch.BackgroundFetchResult.NoData;
}
const timerData = JSON.parse(timerDataString);
const { endTime } = timerData;
const now = Date.now();
const remainingMs = Math.max(0, endTime - now);
// Invia notifica solo se il timer è completato
if (remainingMs <= 0) {
await sendCompletionNotification();
await AsyncStorage.removeItem(STORAGE_KEY);
return BackgroundFetch.BackgroundFetchResult.NewData;
}
return BackgroundFetch.BackgroundFetchResult.NoData;
} catch (error) {
console.error('Errore nel task in background:', error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
// Funzione per inviare la notifica di completamento
async function sendCompletionNotification() {
try {
// Recupera la traduzione dal contesto corrente
let title = "Timer completato!"; // Default
try {
// Prova a recuperare la traduzione salvata
const timerDataString = await AsyncStorage.getItem(STORAGE_KEY);
if (timerDataString) {
const { translatedTitle } = JSON.parse(timerDataString);
if (translatedTitle) {
title = translatedTitle;
}
}
} catch (e) {
// Usa il titolo di default se c'è un errore
}
// Configurazione della notifica
const notificationContent = {
title,
data: { type: 'timer-complete' },
sound: true,
};
// Impostazioni specifiche per piattaforma
if (Platform.OS === 'android') {
Object.assign(notificationContent, {
channelId: 'workout-timer-complete',
priority: 'max',
category: 'alarm', // Importante per la schermata di blocco
vibrationPattern: [0, 400, 200, 400, 200, 400],
});
} else if (Platform.OS === 'ios') {
Object.assign(notificationContent, {
interruptionLevel: 'timeSensitive', // Livello critico per iOS
sound: true,
});
}
// Rimuovi qualsiasi notifica esistente prima
await Notifications.dismissAllNotificationsAsync();
// Invia la notifica immediatamente
await Notifications.scheduleNotificationAsync({
content: notificationContent,
trigger: null, // Consegna immediata
identifier: TIMER_NOTIFICATION_ID,
});
// Vibra al completamento
const vibrationPattern = Platform.OS === 'android'
? [0, 400, 200, 400, 200, 400]
: [0, 500, 200, 500];
Vibration.vibrate(vibrationPattern);
} catch (error) {
console.error('Errore invio notifica di completamento:', error);
}
}
// Props del componente
interface WorkoutTimerProps {
duration: number | null;
isVisible: boolean;
onComplete: () => void;
onDismiss: () => void;
exerciseName?: string;
currentSetNumber?: number;
totalSets?: number;
}
// Componente principale
const WorkoutTimer: React.FC<WorkoutTimerProps> = ({
duration,
isVisible,
onComplete,
onDismiss,
exerciseName,
currentSetNumber,
totalSets,
}) => {
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [appState, setAppState] = useState(AppState.currentState);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const backgroundTimeRef = useRef<number | null>(null);
const endTimeRef = useRef<number | null>(null);
const scale = useSharedValue(1);
const { t } = useLanguage();
// Richiedi permessi per le notifiche
useEffect(() => {
const requestPermissions = async () => {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowCriticalAlerts: true, // Importante per schermata di blocco
provideAppNotificationSettings: true,
},
});
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Permessi per le notifiche non concessi');
}
};
requestPermissions();
// Setup del listener per le notifiche ricevute
const notificationListener = Notifications.addNotificationReceivedListener(() => {
// Quando la notifica è ricevuta, completa il timer
completeTimer();
});
// Setup del listener per le notifiche toccate
const responseListener = Notifications.addNotificationResponseReceivedListener(() => {
// Quando la notifica è toccata, completa il timer
completeTimer();
});
return () => {
notificationListener.remove();
responseListener.remove();
cleanupResources();
};
}, []);
// Inizializza il timer quando diventa visibile
useEffect(() => {
if (isVisible && duration !== null) {
// Rimuovi notifiche esistenti
Notifications.dismissAllNotificationsAsync();
// Calcola il tempo di fine
const now = Date.now();
const endTime = now + duration * 1000;
endTimeRef.current = endTime;
setTimeRemaining(duration);
// Avvia il timer e registra il task in background
startTimer();
registerBackgroundTask();
}
return () => {
cleanupResources();
};
}, [isVisible, duration]);
// Gestione dei cambiamenti di stato dell'app (foreground/background)
useEffect(() => {
const subscription = AppState.addEventListener('change', nextAppState => {
if (appState === 'active' && nextAppState.match(/inactive|background/)) {
// App va in background - salva il momento
backgroundTimeRef.current = Date.now();
} else if (appState.match(/inactive|background/) && nextAppState === 'active') {
// App torna in foreground - sincronizza il timer
syncTimerWithStoredEndTime();
}
setAppState(nextAppState);
});
return () => {
subscription.remove();
};
}, [appState]);
// Registra il task in background
const registerBackgroundTask = async () => {
try {
if (duration !== null && endTimeRef.current) {
// Salva i dati del timer in AsyncStorage
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({
endTime: endTimeRef.current,
translatedTitle: t.screens.workoutDetails.timer.timerComplete
}));
// Registra il task se non è già registrato
const isRegistered = await TaskManager.isTaskRegisteredAsync(TIMER_BACKGROUND_TASK);
if (!isRegistered) {
await BackgroundFetch.registerTaskAsync(TIMER_BACKGROUND_TASK, {
minimumInterval: 1, // Controlla frequentemente
stopOnTerminate: false,
startOnBoot: true
});
}
}
} catch (error) {
console.error('Errore registrazione task in background:', error);
}
};
// Sincronizza il timer con il tempo di fine salvato
const syncTimerWithStoredEndTime = async () => {
try {
if (backgroundTimeRef.current && endTimeRef.current) {
const now = Date.now();
const newTimeRemaining = Math.max(0, Math.floor((endTimeRef.current - now) / 1000));
setTimeRemaining(newTimeRemaining);
if (newTimeRemaining <= 0) {
completeTimer();
} else {
startTimer();
}
}
} catch (error) {
console.error('Errore sincronizzazione timer:', error);
}
};
// Pulisci le risorse
const cleanupResources = async () => {
stopTimer();
try {
// Cancella le notifiche programmate
await Notifications.cancelScheduledNotificationAsync(TIMER_NOTIFICATION_ID);
// Non annullare la registrazione del task per permettergli di completarsi
// anche se il componente si smonta
// Rimuovi i dati salvati solo se il timer è stato esplicitamente interrotto
if (!isVisible) {
await AsyncStorage.removeItem(STORAGE_KEY);
}
} catch (error) {
console.error('Errore pulizia risorse:', error);
}
};
// Avvia il timer
const startTimer = () => {
stopTimer();
timerRef.current = setInterval(() => {
setTimeRemaining(prev => {
if (!prev || prev <= 0) {
completeTimer();
return 0;
}
const newTime = prev - 1;
// Anima il timer quando si avvicina al completamento
if (newTime <= 5 && newTime > 0) {
// Anima il testo
scale.value = withSequence(
withTiming(1.2, { duration: 300 }),
withTiming(1, { duration: 300 })
);
// Vibrazione leggera per gli ultimi 5 secondi, solo in foreground
if (appState === 'active') {
Vibration.vibrate(40);
}
}
return newTime;
});
}, 1000);
};
// Ferma il timer
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
// Completa il timer
const completeTimer = async () => {
stopTimer();
setTimeRemaining(0);
// Invia la notifica di completamento solo se NON siamo in foreground
if (appState !== 'active') {
await sendCompletionNotification();
} else {
// Se l'app è in foreground, fornisci feedback aptico
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Vibra il dispositivo al completamento per enfasi
const vibrationPattern = Platform.OS === 'android'
? [0, 400, 200, 400, 200, 400]
: [0, 500, 200, 500];
Vibration.vibrate(vibrationPattern);
}
// Pulisci le risorse
await cleanupResources();
// Notifica il componente parent del completamento
onComplete();
};
// Stile animato per il testo del timer
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
// Gestione dello skip o chiusura timer
const handleSkipOrDismiss = () => {
stopTimer();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
cleanupResources();
onDismiss();
};
if (!isVisible) return null;
return (
<Portal>
<Dialog
visible={isVisible}
onDismiss={handleSkipOrDismiss}
dismissable={false}
style={{
borderRadius: 16,
backgroundColor: Theme.secondaryBackground,
margin: 24,
padding: 16,
}}
>
<View style={{
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}>
{exerciseName && (
<Text style={{
fontSize: 18,
fontWeight: '600',
color: Theme.primaryText,
textAlign: 'center',
marginBottom: 16,
}}>
{exerciseName}
{currentSetNumber && totalSets &&
` - ${t.screens.workoutDetails.set.title.replace('{{number}}', String(currentSetNumber))} (${currentSetNumber}/${totalSets})`}
</Text>
)}
<View style={{
alignItems: 'center',
width: '100%',
gap: 16,
}}>
<Animated.View style={animatedStyle}>
<Text style={{
fontSize: 56,
fontWeight: 'bold',
color: timeRemaining && timeRemaining <= 5 ? Theme.error : Theme.primary,
fontVariant: ['tabular-nums'],
}}>
{formatTime(timeRemaining ?? 0)}
</Text>
</Animated.View>
{duration !== null && (
<ProgressBar
progress={timeRemaining !== null ? 1 - timeRemaining / duration : 0}
style={{
height: 8,
width: '100%',
borderRadius: 4,
}}
color={Theme.primary}
/>
)}
<Button
mode="contained"
onPress={handleSkipOrDismiss}
style={{
marginTop: 16,
borderRadius: 8,
width: '100%',
backgroundColor: Theme.primary,
}}
textColor={Theme.background}
>
{t.screens.workoutDetails.timer.skipRest}
</Button>
</View>
</View>
</Dialog>
</Portal>
);
};
export default WorkoutTimer;
本文标签:
版权声明:本文标题:android - How to Implement a Cross-Platform Background Timer with Notifications in React Native (Expo)? - Stack Overflow 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.betaflare.com/web/1744286066a2598884.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论