import React, {FC, useRef, useEffect, useState} from 'react'; import { StyleSheet, ScrollView, View, Animated, RefreshControl, ActivityIndicator, Platform } from 'react-native'; import {useTranslation} from 'react-i18next'; import { useNavigation, useFocusEffect, useIsFocused } from '@react-navigation/native'; import AsyncStorage from '@react-native-community/async-storage'; import { useExposure, StatusState, StatusType, Status } from 'react-native-exposure-notification-service'; import * as SecureStore from 'expo-secure-store'; import {differenceInCalendarDays} from 'date-fns'; import Container from '../atoms/container'; import Text from '../atoms/text'; import {text, colors} from '../../theme'; import {SPACING_HORIZONTAL} from '../../theme/layouts/shared'; import {Header} from '../molecules/header'; import {Grid} from '../molecules/grid'; import {Message} from '../atoms/message'; import Spacing from '../atoms/spacing'; import Markdown from '../atoms/markdown'; import {ScreenNames} from '../../navigation'; import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; import {useApplication} from '../../providers/context'; import {ArrowLink} from '../molecules/arrow-link'; import { ExposureNotificationsModal, BluetoothNotificationsModal, PushNotificationsModal } from '../organisms/modals'; import {useSettings} from '../../providers/settings'; import {useAppState, useVersion, useA11yElement, A11yView} from '../../hooks'; import {useReminder} from '../../providers/reminder'; import {NewVersionCard} from '../molecules/new-version-card'; import {getExposureDate} from '../../utils/exposure'; const RestrictionsImage = require('../../assets/images/restrictions/image.png'); const ANIMATION_DURATION = 300; const PROMPT_OFFSET = 1000; const getMessage = ({ onboarded, enabled, status, messages, stage, paused }: { onboarded: boolean; enabled: boolean; status: Status; messages: string[]; stage: number; paused?: string | null; }): string => { if (paused) { return 'dashboard:message:paused'; } if (onboarded) { if (enabled && status.state === StatusState.active) { return 'dashboard:message:standard'; } return 'dashboard:message:inactive'; } else { if (stage < 1) { if (enabled && status.state === StatusState.active) { return messages[0]; } return 'dashboard:alternateTourStart'; } else { return messages[stage]; } } }; export const Dashboard: FC = () => { const {t} = useTranslation(); const { initialised, enabled, status, contacts, getCloseContacts, permissions, readPermissions } = useExposure(); const [appState] = useAppState(); const {checked, paused} = useReminder(); const navigation = useNavigation(); const {onboarded, setContext, loadAppData} = useApplication(); const { isolationDuration, isolationCompleteDuration, latestVersion: appLatestVersion } = useSettings(); const [refreshing, setRefreshing] = useState(false); const { focusRef: tourFocus, focusA11yElement: focusTourElem } = useA11yElement(); const { focusRef: dashboardFocus, focusA11yElement: focusDashboardElem } = useA11yElement(); const isFocused = useIsFocused(); const messageOpacity = useRef(new Animated.Value(0)).current; const contentOpacity = useRef(new Animated.Value(0)).current; const gridOpacity = useRef(new Animated.Value(0)).current; const exposureEnabled = useRef(enabled); const bluetoothDisabled = useRef( status.state === 'disabled' && status.type?.includes(StatusType.bluetooth) ); const pushNotificationsDisabled = useRef( permissions.notifications.status === 'not_allowed' ); const [state, setState] = useState<{ stage: number; exposurePrompt: boolean; bluetoothPrompt: boolean; pushNotificationsPrompt: boolean; disabled: boolean; current: string; isolationMessage: string | null; isolationComplete: boolean; default: string; messages: string[]; }>({ stage: onboarded ? -1 : 0, exposurePrompt: false, bluetoothPrompt: false, pushNotificationsPrompt: false, disabled: false, current: t( getMessage({ onboarded, enabled, status, messages: t('dashboard:tour', {returnObjects: true}), stage: onboarded ? -1 : 0, paused }) ), isolationMessage: null, isolationComplete: false, messages: t('dashboard:tour', {returnObjects: true}), default: t('dashboard:message:standard') }); const version = useVersion(); const resetToNormal = () => setState((s) => ({ ...s, isolationComplete: false, isolationMessage: null })); const setExposed = () => setState((s) => ({ ...s, isolationComplete: false, isolationMessage: t('dashboard:exposed') })); const setIsolationComplete = () => setState((s) => ({ ...s, isolationComplete: true, isolationMessage: t('dashboard:isolationComplete') })); const processContactsForMessaging = async () => { let currentStatus = null; try { currentStatus = await SecureStore.getItemAsync('niexposuredate'); } catch (err) { await SecureStore.deleteItemAsync('niexposuredate'); console.log('processContactsForMessaging', err); } if (currentStatus) { const daysDiff = differenceInCalendarDays( new Date(), new Date(Number(currentStatus)) ); const withIsolation = isolationDuration + isolationCompleteDuration; if (daysDiff >= withIsolation) { await SecureStore.deleteItemAsync('niexposuredate'); return resetToNormal(); } if (daysDiff >= isolationDuration && daysDiff < withIsolation) { return setIsolationComplete(); } if (contacts && contacts.length > 0) { return setExposed(); } } return resetToNormal(); }; const checkLatestExposure = async () => { const latestExposure = getExposureDate(contacts); if (latestExposure) { await SecureStore.setItemAsync( 'niexposuredate', String(latestExposure.getTime()) ); } processContactsForMessaging(); }; const onRefresh = () => { setRefreshing(true); loadAppData().then(() => setRefreshing(false)); }; useEffect(() => { onRefresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { getCloseContacts(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); useEffect(() => { checkLatestExposure(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [contacts, status]); useFocusEffect( React.useCallback(() => { if (!isFocused || appState !== 'active') { return; } readPermissions(); }, [isFocused, appState, readPermissions]) ); useEffect(() => { setState((s) => ({ ...s, current: t( getMessage({ onboarded, enabled, status, messages: state.messages, stage: state.stage, paused }) ) })); exposureEnabled.current = enabled; bluetoothDisabled.current = status.state === 'disabled' && status.type?.includes(StatusType.bluetooth); pushNotificationsDisabled.current = permissions.notifications.status === 'not_allowed'; if (!exposureEnabled.current && onboarded) { setTimeout(() => { if (!exposureEnabled.current) { setState((s) => ({ ...s, exposurePrompt: true })); } }, PROMPT_OFFSET); } else if (bluetoothDisabled.current && onboarded) { setTimeout(() => { if (bluetoothDisabled.current) { setState((s) => ({ ...s, bluetoothPrompt: true })); } }, PROMPT_OFFSET); } else if (pushNotificationsDisabled.current && onboarded) { setTimeout(() => { if ( pushNotificationsDisabled.current && exposureEnabled.current && !bluetoothDisabled.current ) { setState((s) => ({ ...s, pushNotificationsPrompt: true })); } }, PROMPT_OFFSET); } else if (onboarded && exposureEnabled.current) { setState((s) => ({ ...s, exposurePrompt: false })); } setTimeout(() => checkLatestExposure(), 100); // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, onboarded, status, permissions]); const animateTourIn = () => { setState((s) => ({...s, disabled: true})); Animated.parallel([ Animated.timing(messageOpacity, { toValue: 1, duration: ANIMATION_DURATION, useNativeDriver: true }), Animated.timing(gridOpacity, { toValue: 1, duration: ANIMATION_DURATION, useNativeDriver: true }) ]).start(() => { setState((s) => ({...s, disabled: false})); }); }; const animateTourOut = () => { setState((s) => ({...s, disabled: true})); Animated.parallel([ Animated.timing(messageOpacity, { toValue: 0, duration: ANIMATION_DURATION, useNativeDriver: true }), Animated.timing(gridOpacity, { toValue: 0, duration: ANIMATION_DURATION, useNativeDriver: true }) ]).start(() => { if (state.stage < state.messages.length - 1) { setState((s) => ({ ...s, stage: state.stage + 1, current: getMessage({ onboarded, enabled, status, messages: state.messages, stage: state.stage + 1 }) })); animateTourIn(); } else { setState((s) => ({ ...s, stage: -1, current: s.default })); setContext({onboarded: true}); animateDashboard(); } }); }; const animateDashboard = () => { setState((s) => ({...s, disabled: true})); Animated.parallel([ Animated.timing(messageOpacity, { toValue: 1, duration: ANIMATION_DURATION, useNativeDriver: true }), Animated.timing(contentOpacity, { toValue: 1, duration: ANIMATION_DURATION, useNativeDriver: true }), Animated.timing(gridOpacity, { toValue: 1, duration: ANIMATION_DURATION, useNativeDriver: true }) ]).start(() => { AsyncStorage.setItem('scot.onboarded', 'true'); setState((s) => ({...s, disabled: false})); }); }; useEffect(() => { if (onboarded) { setTimeout(() => animateDashboard(), 200); } else { setTimeout(() => animateTourIn(), 200); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!state.disabled) { focusTourElem(); } }, [focusTourElem, state.disabled]); useEffect(() => { if (onboarded && !state.disabled) { focusDashboardElem(); } }, [focusDashboardElem, onboarded, state.disabled]); const handleTour = () => { animateTourOut(); }; if (!initialised || !checked) { return ( <> <Container center="both"> <ActivityIndicator color={colors.darkGrey} size="large" /> </Container> </> ); } return ( <> <Header /> <ScrollView refreshControl={ <RefreshControl refreshing={onboarded && refreshing} onRefresh={onRefresh} /> }> {onboarded && appLatestVersion && version && appLatestVersion !== version?.display && ( <View style={blockStyles.block}> <NewVersionCard /> <Spacing s={16} /> </View> )} <Spacing s={onboarded ? 15 : 65} /> {!onboarded && state.stage > -1 && ( <> <View style={styles.dots}> {[-1, 0, 1, 2].map((x) => ( <Animated.View key={`step-${x}`} style={[ styles.dot, { backgroundColor: state.stage > x ? colors.primaryPurple : colors.lighterPurple } ]} /> ))} </View> <A11yView ref={tourFocus} accessible accessibilityHint={ t('dashboard:tourA11y', {returnObjects: true})[state.stage] } /> <Animated.View style={{opacity: messageOpacity}}> <TouchableWithoutFeedback onPress={() => handleTour()}> <Markdown markdownStyles={markDownStyles}> {state.current} </Markdown> </TouchableWithoutFeedback> <Spacing s={30} /> <ArrowLink onPress={() => { if (!state.disabled) { handleTour(); } }} accessibilityHint={t('dashboard:tourActionHint')} invert> <Text variant="h3" color="pink" style={styles.nextLink}> {t('dashboard:tourAction')} </Text> </ArrowLink> </Animated.View> </> )} {onboarded && state.isolationMessage && ( <> <Animated.View style={{ opacity: messageOpacity }}> <View accessible ref={dashboardFocus}> <Markdown markdownStyles={ state.isolationComplete ? markDownStyles : markDownStylesExposed }> {state.isolationMessage} </Markdown> </View> {!state.isolationComplete && ( <> <Spacing s={30} /> <ArrowLink onPress={() => navigation.navigate(ScreenNames.closeContact) } accessibilityHint={t('dashboard:exposedAction')} invert> <Text variant="h3" color="pink" style={styles.nextLink}> {t('dashboard:tourAction')} </Text> </ArrowLink> </> )} {state.isolationComplete && ( <> <Spacing s={20} /> <Text style={blockStyles.block} inline color="darkGrey"> {t('dashboard:isolationCompleteSupplemental')} </Text> </> )} </Animated.View> <Spacing s={30} /> <Animated.View style={[{opacity: contentOpacity}, blockStyles.block]}> <Message /> </Animated.View> </> )} {onboarded && !state.isolationMessage && ( <Animated.View style={{opacity: messageOpacity}}> <View accessible ref={dashboardFocus}> <Markdown markdownStyles={markDownStyles}> {state.current} </Markdown> </View> {state.stage === -1 && !paused && ( <> <Spacing s={20} /> <Text style={blockStyles.block} inline color="darkGrey"> {t(`dashboard:message:bluetooth:${Platform.OS}`)} </Text> </> )} {state.stage === -1 && paused && ( <> <Spacing s={20} /> <Text style={blockStyles.block} inline color="darkGrey"> {t('dashboard:message:pausedSupplemental')} </Text> </> )} </Animated.View> )} <Spacing s={30} /> <Grid onboarded={onboarded} stage={state.stage} opacity={gridOpacity} onboardingCallback={() => handleTour()} /> {state.isolationMessage && <Spacing s={34} />} {onboarded && !state.isolationMessage && ( <> <Animated.View style={[{opacity: contentOpacity}, blockStyles.block]}> <Spacing s={30} /> <Message /> <Spacing s={16} /> <Message image={RestrictionsImage} markdown={t('restrictions:message')} accessibilityLabel={t('restrictions:a11y:label')} accessibilityHint={t('restrictions:a11y:hint')} link={t('links:r')} /> <Spacing s={45} /> </Animated.View> </> )} {onboarded && ( <Text variant="h4" color="primaryPurple" align="center"> {t('dashboard:thanks')} </Text> )} <Spacing s={60} /> </ScrollView> {checked && !paused && state.exposurePrompt && ( <ExposureNotificationsModal isVisible={state.exposurePrompt} onBackdropPress={() => setState((s) => ({...s, exposurePrompt: false})) } onClose={() => setState((s) => ({...s, exposurePrompt: false}))} /> )} {checked && !paused && state.bluetoothPrompt && ( <BluetoothNotificationsModal isVisible={state.bluetoothPrompt} onBackdropPress={() => setState((s) => ({...s, bluetoothPrompt: false})) } onClose={() => setState((s) => ({...s, bluetoothPrompt: false}))} /> )} {checked && !paused && state.pushNotificationsPrompt && ( <PushNotificationsModal isVisible={state.pushNotificationsPrompt} onBackdropPress={() => setState((s) => ({...s, pushNotificationsPrompt: false})) } onClose={() => setState((s) => ({...s, pushNotificationsPrompt: false})) } /> )} </> ); }; const blockStyles = StyleSheet.create({ block: { marginHorizontal: SPACING_HORIZONTAL } }); const markDownStyles = StyleSheet.create({ ...blockStyles, text: { ...text.h1Heading, ...blockStyles.block, color: colors.primaryPurple } }); const markDownStylesExposed = StyleSheet.create({ ...markDownStyles, text: { ...markDownStyles.text, color: colors.errorRed } }); const styles = StyleSheet.create({ nextLink: { paddingLeft: SPACING_HORIZONTAL, paddingRight: 5 }, dots: { flexDirection: 'row', marginBottom: 30, paddingLeft: 46, paddingRight: SPACING_HORIZONTAL }, dot: { width: 5, height: 5, borderRadius: 5, backgroundColor: colors.primaryPurple, marginRight: 10 } });