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;

本文标签: