diff --git a/App.tsx b/App.tsx index 25a4799e..8955ba7f 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import Ionicons from '@expo/vector-icons/Ionicons'; import {SelectProvider} from '@mobile-reality/react-native-select-pro'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {getStateFromPath, NavigationContainer, PathConfigMap, RouteProp, useNavigationContainerRef} from '@react-navigation/native'; +import {getStateFromPath, NavigationContainer, PathConfigMap, useNavigationContainerRef} from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import {ActivityIndicator, AppState, AppStateStatus, Image, Platform, StatusBar, StyleSheet, View} from 'react-native'; import {SafeAreaProvider} from 'react-native-safe-area-context'; @@ -14,8 +12,6 @@ import * as BackgroundFetch from 'expo-background-task'; import Constants from 'expo-constants'; import * as TaskManager from 'expo-task-manager'; -import {merge} from 'lodash'; - import AsyncStorage from '@react-native-async-storage/async-storage'; import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'; import {focusManager, QueryCache, QueryClient, useQueryClient} from '@tanstack/react-query'; @@ -23,10 +19,6 @@ import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'; import {ClientContext, ClientProps, productionHosts, stagingHosts} from 'clientContext'; import {ActionToast, ErrorToast, InfoToast, SuccessToast, WarningToast} from 'components/content/Toast'; -import {HomeTabScreen} from 'components/screens/HomeScreen'; -import {MenuStackScreen} from 'components/screens/MenuScreen'; -import {ObservationsTabScreen} from 'components/screens/ObservationsScreen'; -import {WeatherScreen} from 'components/screens/WeatherScreen'; import {HTMLRendererConfig} from 'components/text/HTML'; import ImageCache, {queryKeyPrefix} from 'hooks/useCachedImageURI'; import {useOnlineManager} from 'hooks/useOnlineManager'; @@ -35,8 +27,6 @@ import {logFilePath, logger} from 'logger'; import {LoggerContext, LoggerProps} from 'loggerContext'; import {prefetchAllActiveForecasts} from 'network/prefetchAllActiveForecasts'; import Toast, {ToastConfigParams} from 'react-native-toast-message'; -import {TabNavigatorParamList} from 'routes'; -import {colorLookup} from 'theme'; import {AvalancheCenterWebsites} from 'types/nationalAvalancheCenter'; import 'date-time-format-timezone'; @@ -46,7 +36,7 @@ import {QUERY_CACHE_ASYNC_STORAGE_KEY} from 'data/asyncStorageKeys'; import * as FileSystem from 'expo-file-system'; import {PreferencesProvider, usePreferences} from 'Preferences'; import {NotFoundError} from 'types/requests'; -import {formatRequestedTime, RequestedTime} from 'utils/date'; +import {RequestedTime} from 'utils/date'; import Mapbox from '@rnmapbox/maps'; import {Integration} from '@sentry/types'; @@ -55,6 +45,7 @@ import * as messages from 'compiled-lang/en.json'; import {Button} from 'components/content/Button'; import {Center, VStack} from 'components/core'; import {KillSwitchMonitor} from 'components/KillSwitchMonitor'; +import {DrawerNavigator} from 'components/screens/navigation/Drawer'; import {Body, BodyBlack, Title3Black} from 'components/text'; import * as Linking from 'expo-linking'; import * as Updates from 'expo-updates'; @@ -237,8 +228,6 @@ const asyncStoragePersister = createAsyncStoragePersister({ key: QUERY_CACHE_ASYNC_STORAGE_KEY, }); -const TabNavigator = createBottomTabNavigator(); - // We add the listener at startup and never plan to stop listening, so there's // no need to unsubscribe here. AppState.addEventListener('change', (status: AppStateStatus) => { @@ -367,27 +356,6 @@ const BaseApp: React.FunctionComponent<{ }); }, [setUpdateStatus, logger]); - const tabNavigatorScreenOptions = useCallback( - ({route: {name}}: {route: RouteProp}) => ({ - headerShown: false, - tabBarIcon: ({color, size}: {focused: boolean; color: string; size: number}) => { - if (name === 'Home') { - return ; - } else if (name === 'Observations') { - return ; - } else if (name === 'Weather Data') { - return ; - } else if (name === 'Menu') { - return ; - } - }, - // these two properties should really take ColorValue but oh well - tabBarActiveTintColor: colorLookup('primary') as string, - tabBarInactiveTintColor: colorLookup('text.secondary') as string, - }), - [], - ); - const [startupPaused, {off: unpauseStartup}] = useToggle(process.env.EXPO_PUBLIC_PAUSE_ON_STARTUP === 'true'); if (updateStatus !== 'ready' || preferences.mixpanelUserId == '') { @@ -514,52 +482,9 @@ const BaseApp: React.FunctionComponent<{ - + - - - {state => - HomeTabScreen( - merge(state, { - route: { - params: { - requestedTime: formatRequestedTime(requestedTime), - }, - }, - }), - ) - } - - - {state => - ObservationsTabScreen( - merge(state, { - route: { - params: { - requestedTime: formatRequestedTime(requestedTime), - }, - }, - }), - ) - } - - - {state => - WeatherScreen( - merge(state, { - route: { - params: { - requestedTime: formatRequestedTime(requestedTime), - }, - }, - }), - ) - } - - - {state => MenuStackScreen(state, queryCache, staging, setStaging)} - - + diff --git a/components/AvalancheCenterSelector.tsx b/components/AvalancheCenterSelector.tsx index 14669580..9524eec8 100644 --- a/components/AvalancheCenterSelector.tsx +++ b/components/AvalancheCenterSelector.tsx @@ -10,15 +10,16 @@ import {incompleteQueryState, QueryState} from 'components/content/QueryState'; import {useAllAvalancheCenterMetadata} from 'hooks/useAllAvalancheCenterMetadata'; import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabilities'; import {usePostHog} from 'posthog-react-native'; -import {MenuStackParamList, TabNavigationProps} from 'routes'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {MainStackNavigationProps, MainStackParamList} from 'routes'; import {AvalancheCenter, AvalancheCenterID} from 'types/nationalAvalancheCenter'; export const AvalancheCenterSelector: React.FunctionComponent<{ currentCenterId: AvalancheCenterID; setAvalancheCenter: (center: AvalancheCenterID) => void; }> = ({currentCenterId, setAvalancheCenter}) => { - const navigation = useNavigation(); - const route = useRoute['route']>(); + const navigation = useNavigation(); + const route = useRoute['route']>(); const capabilitiesResult = useAvalancheCenterCapabilities(); const capabilities = capabilitiesResult.data; const whichCenters = route.params.debugMode ? AvalancheCenters.AllCenters : AvalancheCenters.SupportedCenters; @@ -26,12 +27,8 @@ export const AvalancheCenterSelector: React.FunctionComponent<{ const setAvalancheCenterWrapper = React.useCallback( (center: AvalancheCenterID) => { setAvalancheCenter(center); - // We need to clear navigation state to force all screens from the - // previous avalanche center selection to unmount - navigation.reset({ - index: 0, - routes: [{name: 'Home'}], - }); + + navigation.goBack(); }, [setAvalancheCenter, navigation], ); @@ -42,6 +39,8 @@ export const AvalancheCenterSelector: React.FunctionComponent<{ }, [postHog]); useFocusEffect(recordAnalytics); + const insets = useSafeAreaInsets(); + if (incompleteQueryState(capabilitiesResult, ...metadataResults) || !capabilities) { return ; } @@ -64,6 +63,7 @@ export const AvalancheCenterSelector: React.FunctionComponent<{ bg="white" px={16} py={8} + paddingBottom={insets.bottom} /> ); diff --git a/components/AvalancheForecastZoneMap.tsx b/components/AvalancheForecastZoneMap.tsx index 7b3565a1..7e4159dd 100644 --- a/components/AvalancheForecastZoneMap.tsx +++ b/components/AvalancheForecastZoneMap.tsx @@ -22,21 +22,21 @@ import {useMapLayerAvalancheWarnings} from 'hooks/useMapLayerAvalancheWarnings'; import {LoggerContext, LoggerProps} from 'loggerContext'; import {usePostHog} from 'posthog-react-native'; import {usePreferences} from 'Preferences'; -import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; -import {HomeStackNavigationProps, TabNavigationProps} from 'routes'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {MainStackNavigationProps, TabNavigationProps} from 'routes'; import {AvalancheCenterID, DangerLevel, ForecastPeriod, isSupportedCenter, MapLayerFeature, ProductType} from 'types/nationalAvalancheCenter'; import {formatRequestedTime, RequestedTime, requestedTimeToUTCDate, utcDateToLocalTimeString} from 'utils/date'; import {Camera, MapState} from '@rnmapbox/maps'; import {defaultMapRegionForGeometries} from 'components/helpers/geographicCoordinates'; import {useAllMapLayers} from 'hooks/useAllMapLayers'; -import Toast from 'react-native-toast-message'; export interface MapProps { + center_id: AvalancheCenterID; requestedTime: RequestedTime; } -export const AvalancheForecastZoneMap: React.FunctionComponent = ({requestedTime}: MapProps) => { +export const AvalancheForecastZoneMap: React.FunctionComponent = ({center_id, requestedTime}: MapProps) => { const {logger} = React.useContext(LoggerContext); const {preferences, setPreferences} = usePreferences(); @@ -46,27 +46,25 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req const allMapLayersResult = useAllMapLayers(); const allMapLayers = allMapLayersResult.data; - const metadataResult = useAvalancheCenterMetadata(center); + const metadataResult = useAvalancheCenterMetadata(center_id); const metadata = metadataResult.data; - const forecastResults = useMapLayerAvalancheForecasts(center, requestedTime, allMapLayers, metadata); - const warningResults = useMapLayerAvalancheWarnings(center, requestedTime, allMapLayers); + const forecastResults = useMapLayerAvalancheForecasts(center_id, requestedTime, allMapLayers, metadata); + const warningResults = useMapLayerAvalancheWarnings(center_id, requestedTime, allMapLayers); - const navigation = useNavigation(); + const navigation = useNavigation(); const [selectedZoneId, setSelectedZoneId] = useState(null); const topElements = React.useRef(null); - const insets = useSafeAreaInsets(); - const postHog = usePostHog(); const recordAnalytics = useCallback(() => { - if (postHog && center) { + if (postHog && center_id) { postHog.screen('avalancheForecastMap', { - center: center, + center: center_id, }); } - }, [postHog, center]); + }, [postHog, center_id]); useFocusEffect(recordAnalytics); const onMapPresOutsideOfPolygon = useCallback( @@ -89,14 +87,8 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req const selectedZoneCenter = zone.center_id; if (isSupportedCenter(selectedZoneCenter)) { setSelectedZoneId(zone.zone_id); - if (selectedZoneCenter !== center) { + if (selectedZoneCenter !== center_id) { setPreferences({center: selectedZoneCenter}); - // This is purely for debugging purposes - Toast.show({ - type: 'info', - text1: `Your preferred center has changed to: ${selectedZoneCenter}`, - position: 'bottom', - }); } } else { Alert.alert(`${selectedZoneCenter} is not supported`, `Please go to their website to view the full forecast for ${selectedZoneCenter} or select another center`, [ @@ -112,10 +104,10 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req } } }, - [navigation, selectedZoneId, center, requestedTime, setSelectedZoneId, setPreferences], + [navigation, selectedZoneId, center_id, requestedTime, setSelectedZoneId, setPreferences], ); - const preferredCenterFeatures = useMemo(() => allMapLayers?.features.filter(feature => feature.properties.center_id === center), [allMapLayers, center]); + const preferredCenterFeatures = useMemo(() => allMapLayers?.features.filter(feature => feature.properties.center_id === center_id), [allMapLayers, center_id]); const avalancheCenterMapRegion = useMemo(() => defaultMapRegionForGeometries(preferredCenterFeatures?.map(feature => feature.geometry)), [preferredCenterFeatures]); @@ -123,6 +115,12 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req // and aren't re-evaluated on render. Fun! const mapCameraRef = useRef(null); const controller = useRef(new AnimatedMapWithDrawerController(AnimatedDrawerState.Hidden, avalancheCenterMapRegion, mapCameraRef, logger)); + + const reanimateOnFocus = useCallback(() => { + controller.current.forceAnimateMapRegion(); + }, [controller]); + useFocusEffect(reanimateOnFocus); + React.useEffect(() => { controller.current.animateUsingUpdatedAvalancheCenterMapRegion(avalancheCenterMapRegion); }, [avalancheCenterMapRegion, controller]); @@ -157,14 +155,8 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req const onSelectCenter = useCallback( (center: AvalancheCenterID) => { setPreferences({center: center, hasSeenCenterPicker: true}); - // We need to clear navigation state to force all screens from the - // previous avalanche center selection to unmount - navigation.reset({ - index: 0, - routes: [{name: 'Home'}], - }); }, - [setPreferences, navigation], + [setPreferences], ); const onCameraChanged = useCallback( @@ -270,7 +262,7 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req const zones = useMemo(() => (zonesById !== undefined ? Object.keys(zonesById).map(k => zonesById[k]) : []), [zonesById]); - const selectedACZones = useMemo(() => zones.filter(zone => zone.feature.properties.center_id === center), [zones, center]); + const selectedACZones = useMemo(() => zones.filter(zone => zone.feature.properties.center_id === center_id), [zones, center_id]); const showAvalancheCenterSelectionModal = useMemo(() => !preferences.hasSeenCenterPicker, [preferences.hasSeenCenterPicker]); @@ -306,7 +298,7 @@ export const AvalancheForecastZoneMap: React.FunctionComponent = ({req onCameraChanged={onCameraChanged} /> - + @@ -349,7 +341,7 @@ const AvalancheForecastZoneCard: React.FunctionComponent<{ zone: MapViewZone; }> = React.memo(({date, zone}: {date: RequestedTime; zone: MapViewZone}) => { const {width} = useWindowDimensions(); - const navigation = useNavigation(); + const navigation = useNavigation(); const dangerLevel = zone.danger_level ?? DangerLevel.None; const dangerColor = colorFor(dangerLevel); diff --git a/components/FeatureFlagsDebugger.tsx b/components/FeatureFlagsDebugger.tsx index 5c2662f4..d5b8d15f 100644 --- a/components/FeatureFlagsDebugger.tsx +++ b/components/FeatureFlagsDebugger.tsx @@ -7,10 +7,10 @@ import {Body, BodySmBlack, Caption1Semibold} from 'components/text'; import React, {useCallback, useEffect, useState} from 'react'; import {FlatList, StyleSheet, Switch} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {MenuStackParamList} from 'routes'; +import {MainStackParamList} from 'routes'; import {colorLookup} from 'theme'; -export const FeatureFlagsDebuggerScreen = (_: NativeStackScreenProps) => { +export const FeatureFlagsDebuggerScreen = (_: NativeStackScreenProps) => { return ( diff --git a/components/content/BottomTabNavigationHeader.tsx b/components/content/BottomTabNavigationHeader.tsx new file mode 100644 index 00000000..b24f62c4 --- /dev/null +++ b/components/content/BottomTabNavigationHeader.tsx @@ -0,0 +1,48 @@ +import {Ionicons} from '@expo/vector-icons'; +import {BottomTabHeaderProps} from '@react-navigation/bottom-tabs'; +import {DrawerActions} from '@react-navigation/native'; +import {AvalancheCenterLogo} from 'components/AvalancheCenterLogo'; +import {HStack, View} from 'components/core'; +import {Title3Black} from 'components/text'; +import {usePreferences} from 'Preferences'; +import React, {useCallback} from 'react'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {colorLookup} from 'theme'; + +export const BottomTabNavigationHeader: React.FunctionComponent = ({navigation}) => { + const {preferences} = usePreferences(); + const centerId = preferences.center; + + const title = centerId as string; + const TextComponent = Title3Black; + const insets = useSafeAreaInsets(); + + const openDrawer = useCallback(() => { + navigation.dispatch(DrawerActions.openDrawer()); + }, [navigation]); + + return ( + + + + + + + + {title} + + + + + + + ); +}; diff --git a/components/content/NavigationHeader.tsx b/components/content/NavigationHeader.tsx index 0c2f00ff..a4e0ab41 100644 --- a/components/content/NavigationHeader.tsx +++ b/components/content/NavigationHeader.tsx @@ -3,23 +3,22 @@ import {getHeaderTitle} from '@react-navigation/elements'; import {NativeStackHeaderProps} from '@react-navigation/native-stack'; import {HStack, View} from 'components/core'; import {GenerateObservationShareLink} from 'components/observations/ObservationUrlMapping'; -import {Title1Black, Title3Black} from 'components/text'; +import {Title3Black} from 'components/text'; import {logger} from 'logger'; +import {usePreferences} from 'Preferences'; import React, {useCallback} from 'react'; import {Platform, Share} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {colorLookup} from 'theme'; import {AvalancheCenterID, AvalancheCenterWebsites, reverseLookup} from 'types/nationalAvalancheCenter'; -export const NavigationHeader: React.FunctionComponent< - NativeStackHeaderProps & { - center_id: AvalancheCenterID; - large?: boolean; - } -> = ({navigation, route, options, back, center_id, large}) => { +export const NavigationHeader: React.FunctionComponent = ({navigation, route, options, back}) => { + const {preferences} = usePreferences(); + const centerId = preferences.center; + let share: boolean = false; let firstOpen: boolean = false; - let shareCenterId: AvalancheCenterID = center_id; + let shareCenterId: AvalancheCenterID = centerId; const shareParams: {share: boolean; share_url: string} = route?.params as {share: boolean; share_url: string}; if (shareParams?.share) { @@ -35,7 +34,7 @@ export const NavigationHeader: React.FunctionComponent< } const title = getHeaderTitle(options, route.name); - const TextComponent = large ? Title1Black : Title3Black; + const TextComponent = Title3Black; const insets = useSafeAreaInsets(); const goBack = useCallback(() => { if (share) { @@ -76,16 +75,15 @@ export const NavigationHeader: React.FunctionComponent< }, [shareUrl]); return ( - // On phones with notches, the insets.top value will be non-zero and we don't need additional padding on top. - // On phones without notches, the insets.top value will be 0 and we don't want the header to be flush with the top of the screen. - - + // Setting the top padding to insets.top correctly aligns the view underneath the notches on iPhone. Trying to set the padding ourselves could lead to unexpected behavior + + {back ? ( )} - + + {title} + {shareUrl ? ( (); + const navigation = useNavigation(); const [zoneName, setZoneName] = useState(''); React.useEffect(() => { if (forecast) { diff --git a/components/forecast/SynopsisTab.tsx b/components/forecast/SynopsisTab.tsx index 7b4a70ac..0f02295f 100644 --- a/components/forecast/SynopsisTab.tsx +++ b/components/forecast/SynopsisTab.tsx @@ -14,7 +14,7 @@ import {useRefresh} from 'hooks/useRefresh'; import {useSynopsis} from 'hooks/useSynopsis'; import {RefreshControl, ScrollView} from 'react-native'; import Toast from 'react-native-toast-message'; -import {HomeStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import {AvalancheCenterID} from 'types/nationalAvalancheCenter'; import {RequestedTimeString, parseRequestedTimeString, utcDateToLocalTimeString} from 'utils/date'; @@ -32,7 +32,7 @@ export const SynopsisTab: React.FunctionComponent<{ const {isRefreshing, refresh} = useRefresh(synopsisResult.refetch); const onRefresh = useCallback(() => void refresh(), [refresh]); - const navigation = useNavigation(); + const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('beforeRemove', () => { Toast.hide(); diff --git a/components/forecast/WeatherTab.tsx b/components/forecast/WeatherTab.tsx index 7448f8b6..6f91a713 100644 --- a/components/forecast/WeatherTab.tsx +++ b/components/forecast/WeatherTab.tsx @@ -22,7 +22,7 @@ import {LoggerContext, LoggerProps} from 'loggerContext'; import {usePostHog} from 'posthog-react-native'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {RefreshControl, ScrollView} from 'react-native'; -import {HomeStackParamList, TabNavigationProps} from 'routes'; +import {MainStackParamList, TabNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import { AvalancheCenterID, @@ -41,7 +41,7 @@ import { import {NotFoundError} from 'types/requests'; import {RequestedTime, RequestedTimeString, formatRequestedTime, pacificDateToDayOfWeekString, parseRequestedTimeString, utcDateToLocalTimeString} from 'utils/date'; -type ForecastNavigationProp = CompositeNavigationProp, TabNavigationProps>; +type ForecastNavigationProp = CompositeNavigationProp, TabNavigationProps>; interface WeatherTabProps { zone: AvalancheForecastZone; diff --git a/components/map/AnimatedCards.tsx b/components/map/AnimatedCards.tsx index b9f0e125..e705709c 100644 --- a/components/map/AnimatedCards.tsx +++ b/components/map/AnimatedCards.tsx @@ -216,6 +216,11 @@ export class AnimatedMapWithDrawerController { } } + // When switching centers from the observation or weather tab, the map view doesn't animate. This forces the animation to happen when it becomes the focus screen again + forceAnimateMapRegion() { + this.animateMapRegion(); + } + // This function gets called many times in short succession when the layout changes. We debounce it so that // we only try to animate after layout changes are complete. ANIMATION_DEBOUNCE_MS = 250; diff --git a/components/observations/ObservationDetailView.tsx b/components/observations/ObservationDetailView.tsx index de51a022..71f3ed48 100644 --- a/components/observations/ObservationDetailView.tsx +++ b/components/observations/ObservationDetailView.tsx @@ -3,7 +3,7 @@ import {Image, ScrollView, StyleSheet} from 'react-native'; import Ionicons from '@expo/vector-icons/Ionicons'; import {useFocusEffect, useNavigation} from '@react-navigation/native'; -import {SafeAreaView} from 'react-native-safe-area-context'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {CameraBounds, MarkerView} from '@rnmapbox/maps'; import {colorFor} from 'components/AvalancheDangerTriangle'; @@ -21,7 +21,7 @@ import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabiliti import {useNACObservation} from 'hooks/useNACObservation'; import {useNWACObservation} from 'hooks/useNWACObservation'; import {usePostHog} from 'posthog-react-native'; -import {ObservationsStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import { Activity, @@ -180,7 +180,7 @@ export const ObservationCard: React.FunctionComponent<{ mapLayerFeatures: MapLayerFeature[]; capabilities: AllAvalancheCenterCapabilities; }> = ({observation, mapLayerFeatures, capabilities}) => { - const navigation = useNavigation(); + const navigation = useNavigation(); const {avalanches_observed, avalanches_triggered, avalanches_caught} = observation.instability; const zone_name = observation.location_point?.lat && observation.location_point?.lng && matchesZone(mapLayerFeatures, observation.location_point?.lat, observation.location_point?.lng); @@ -201,172 +201,169 @@ export const ObservationCard: React.FunctionComponent<{ }, [postHog, observation.center_id, observation.id]); useFocusEffect(recordAnalytics); + const insets = useSafeAreaInsets(); + const nePosition: Position = [(observation.location_point.lng ?? 0) + 0.075 / 2, (observation.location_point.lat ?? 0) + 0.075 / 2]; const swPosition: Position = [(observation.location_point.lng ?? 0) - 0.075 / 2, (observation.location_point.lat ?? 0) - 0.075 / 2]; const initialCameraBounds: CameraBounds = {ne: nePosition, sw: swPosition}; return ( - - - - - - - - Observed - - {observationDateToLocalShortDateString(observation.start_date)} - - - - Submitted - - {utcDateToLocalShortDateString(observation.created_at)} - - - - Author - - {observation.name || 'Unknown'} - - + + + + + + + Observed + + {observationDateToLocalShortDateString(observation.start_date)} + + + + Submitted + + {utcDateToLocalShortDateString(observation.created_at)} + + + + Author + + {observation.name || 'Unknown'} + + + + + Summary}> + + {observation.location_point.lat && observation.location_point.lng && !isPlaceholder(observation.location_point.lat, observation.location_point.lng) && ( + + + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports */} + + + + )} + + {observation.location_name && } + + + {observation.observation_summary && } + + Signs of Instability + + {/* Avalanche section */} + + + {avalanches_observed ? 'Avalanche(s) Observed' : 'No Avalanche(s) Observed'} - - Summary}> - - {observation.location_point.lat && observation.location_point.lng && !isPlaceholder(observation.location_point.lat, observation.location_point.lng) && ( - - - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports */} - - - - )} - - {observation.location_name && } - - - {observation.observation_summary && } - - Signs of Instability - - {/* Avalanche section */} + {avalanches_triggered && ( - - {avalanches_observed ? 'Avalanche(s) Observed' : 'No Avalanche(s) Observed'} + + {'Avalanche(s) Triggered'} - {avalanches_triggered && ( - - - {'Avalanche(s) Triggered'} - - )} - {avalanches_caught && ( - - - {'Caught In Avalanche'} - - )} - {/* Collapsing section */} - - - - {observation.instability.collapsing - ? `${FormatInstabilityDistribution(observation.instability.collapsing_description as InstabilityDistribution)} Collapsing` - : 'No Collapsing Observed'} - - - {/* Cracking section */} + )} + {avalanches_caught && ( - - - {observation.instability.cracking - ? `${FormatInstabilityDistribution(observation.instability.cracking_description as InstabilityDistribution)} Cracking` - : 'No Cracking Observed'} - + + {'Caught In Avalanche'} + )} + {/* Collapsing section */} + + + + {observation.instability.collapsing + ? `${FormatInstabilityDistribution(observation.instability.collapsing_description as InstabilityDistribution)} Collapsing` + : 'No Collapsing Observed'} + + + {/* Cracking section */} + + + + {observation.instability.cracking + ? `${FormatInstabilityDistribution(observation.instability.cracking_description as InstabilityDistribution)} Cracking` + : 'No Cracking Observed'} + + - {observation.instability_summary && ( - - Instability Comments - - - )} + {observation.instability_summary && ( + + Instability Comments + + + )} + + + {(observation.media ?? []).length > 0 && ( + Media}> + + + )} + {((observation.avalanches && observation.avalanches.length > 0) || observation.avalanches_summary) && ( + Avalanches}> + + {observation.avalanches_summary && } + {observation.avalanches && + observation.avalanches.length > 0 && + observation.avalanches.map((item, index) => ( + + {`#${index + 1}${item.location ? `: ${item.location}` : ''}`} + {item.comments && } + + {item.d_size && } + {item.trigger && ( + + )} + + {item.vertical_fall && } + {item.avg_crown_depth && } + {item.width && } + {item.avalanche_type && } + {item.bed_sfc && } + {item.media && ( + + Media + + + )} + + ))} - {(observation.media ?? []).length > 0 && ( - Media}> - - - )} - {((observation.avalanches && observation.avalanches.length > 0) || observation.avalanches_summary) && ( - Avalanches}> + )} + + {observation.advanced_fields && + (observation.advanced_fields.snowpack || + (observation.advanced_fields.snowpack_media && observation.advanced_fields.snowpack_media.length > 0) || + observation.advanced_fields.snowpack_summary) && ( + Snowpack}> - {observation.avalanches_summary && } + {observation.advanced_fields.snowpack_summary && } + {observation.advanced_fields.snowpack_media && ( + + )} + {observation.advanced_fields.snowpack && <>{/* we don't know what fields could be in this thing ... */}} - {observation.avalanches && - observation.avalanches.length > 0 && - observation.avalanches.map((item, index) => ( - - {`#${index + 1}${item.location ? `: ${item.location}` : ''}`} - {item.comments && } - - {item.d_size && } - {item.trigger && ( - - )} - - {item.vertical_fall && } - {item.avg_crown_depth && } - {item.width && } - {item.avalanche_type && } - {item.bed_sfc && } - {item.media && ( - - Media - - - )} - - ))} )} - - {observation.advanced_fields && - (observation.advanced_fields.snowpack || - (observation.advanced_fields.snowpack_media && observation.advanced_fields.snowpack_media.length > 0) || - observation.advanced_fields.snowpack_summary) && ( - Snowpack}> - - {observation.advanced_fields.snowpack_summary && } - {observation.advanced_fields.snowpack_media && ( - - )} - {observation.advanced_fields.snowpack && <>{/* we don't know what fields could be in this thing ... */}} - - - )} - - - - + + + ); }; diff --git a/components/observations/ObservationForm.tsx b/components/observations/ObservationForm.tsx index bbcb843c..ccbb66aa 100644 --- a/components/observations/ObservationForm.tsx +++ b/components/observations/ObservationForm.tsx @@ -33,7 +33,7 @@ import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata'; import {LoggerContext, LoggerProps} from 'loggerContext'; import {usePostHog} from 'posthog-react-native'; import Toast from 'react-native-toast-message'; -import {ObservationsStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import {AvalancheCenterID, InstabilityDistribution, userFacingCenterId} from 'types/nationalAvalancheCenter'; @@ -60,7 +60,7 @@ export const ObservationForm: React.FC<{ const metadata = metadataResult.data; const capabilitiesResult = useAvalancheCenterCapabilities(); const capabilities = capabilitiesResult.data; - const navigation = useNavigation(); + const navigation = useNavigation(); const {logger} = React.useContext(LoggerContext); const formContext = useForm({ defaultValues: defaultObservationFormData(), @@ -299,298 +299,300 @@ export const ObservationForm: React.FC<{ keyboardVerticalOffset={keyboardVerticalOffset}> - - - Help keep the {userFacingCenterId(center_id, capabilities)} community informed by submitting your observation. - - Privacy}> - - - - - - - - General information}> - - - - - - - - - - - - Signs of instability}> - - - - - - Please provide more detail in the Avalanches section below. - - - - - - - - - + + + + Help keep the {userFacingCenterId(center_id, capabilities)} community informed by submitting your observation. + + Privacy}> + + - - - + - - - - - Avalanches}> + + + General information}> - + + + + + + + + + + Signs of instability}> + + + + + + Please provide more detail in the Avalanches section below. + + + + + + + + + + + + + + + - - Field Notes}> - - + Avalanches}> + + + + + + + + Field Notes}> + + - - - - Photos - - - }> - - - - + multiline: true, + }} + disabled={disableFormControls} + /> + + + + Photos + + + }> + + + + - - {mutation.isSuccess && ( - - Thanks for your observation! - - )} - {mutation.isError && ( - - There was an error submitting your observation. - - )} - + + {mutation.isSuccess && ( + + Thanks for your observation! + + )} + {mutation.isError && ( + + There was an error submitting your observation. + + )} + + diff --git a/components/observations/ObservationsListView.tsx b/components/observations/ObservationsListView.tsx index 371f8aa0..40f0eaef 100644 --- a/components/observations/ObservationsListView.tsx +++ b/components/observations/ObservationsListView.tsx @@ -36,7 +36,7 @@ import { SectionListRenderItemInfo, TouchableOpacity, } from 'react-native'; -import {ObservationsStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import {AvalancheCenterID, DangerLevel, MediaType, ObservationFragment, PartnerType, mapFeaturesForCenter} from 'types/nationalAvalancheCenter'; import {RequestedTime, observationDateToLocalDateString, requestedTimeToUTCDate} from 'utils/date'; @@ -68,7 +68,7 @@ interface ObservationFragmentWithPageIndexAndZoneAndSource extends ObservationFr export const ObservationsListView: React.FunctionComponent = ({center_id, requestedTime, additionalFilters}) => { const {logger} = React.useContext(LoggerContext); - const navigation = useNavigation(); + const navigation = useNavigation(); const endDate = requestedTimeToUTCDate(requestedTime); const originalFilterConfig: ObservationFilterConfig = useMemo(() => createDefaultFilterConfig(additionalFilters), [additionalFilters]); const [filterConfig, setFilterConfig] = useState(originalFilterConfig); @@ -487,7 +487,7 @@ export interface ObservationSummaryCardProps { const OBSERVATION_SUMMARY_CARD_HEIGHT = 132; export const ObservationSummaryCard: React.FunctionComponent = React.memo(({source, zone, observation, pending}: ObservationSummaryCardProps) => { - const navigation = useNavigation(); + const navigation = useNavigation(); const avalanches = observation.instability.avalanches_caught || observation.instability.avalanches_observed || observation.instability.avalanches_triggered; const redFlags = observation.instability.collapsing || observation.instability.cracking; const onPress = useCallback(() => { diff --git a/components/observations/ObservationsPortal.tsx b/components/observations/ObservationsPortal.tsx index 6d2a3a91..5bb178e6 100644 --- a/components/observations/ObservationsPortal.tsx +++ b/components/observations/ObservationsPortal.tsx @@ -8,7 +8,7 @@ import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabiliti import {usePostHog} from 'posthog-react-native'; import React, {useCallback} from 'react'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {ObservationsStackNavigationProps} from 'routes'; +import {MainStackNavigationProps, TabNavigationProps} from 'routes'; import {AvalancheCenterID, userFacingCenterId} from 'types/nationalAvalancheCenter'; import {RequestedTime, formatRequestedTime} from 'utils/date'; @@ -16,12 +16,13 @@ export const ObservationsPortal: React.FC<{ center_id: AvalancheCenterID; requestedTime: RequestedTime; }> = ({center_id, requestedTime}) => { - const navigation = useNavigation(); + const tabNavigation = useNavigation(); + const mainNavigation = useNavigation(); const onViewAll = useCallback( - () => navigation.navigate('observationsList', {center_id, requestedTime: formatRequestedTime(requestedTime)}), - [center_id, navigation, requestedTime], + () => tabNavigation.navigate('Observations', {center_id, requestedTime: formatRequestedTime(requestedTime)}), + [center_id, tabNavigation, requestedTime], ); - const onSubmit = useCallback(() => navigation.navigate('observationSubmit'), [navigation]); + const onSubmit = useCallback(() => mainNavigation.navigate('observationSubmit'), [mainNavigation]); const postHog = usePostHog(); const recordAnalytics = useCallback(() => { diff --git a/components/screens/ForecastScreen.tsx b/components/screens/ForecastScreen.tsx index d1ddade7..f340b6ec 100644 --- a/components/screens/ForecastScreen.tsx +++ b/components/screens/ForecastScreen.tsx @@ -1,5 +1,5 @@ -import React, {useCallback} from 'react'; -import {StyleSheet, TouchableOpacity} from 'react-native'; +import React, {useMemo} from 'react'; +import {StyleSheet} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; @@ -8,68 +8,53 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'; import {createMaterialTopTabNavigator, MaterialTopTabScreenProps} from '@react-navigation/material-top-tabs'; import {useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; -import {AvalancheCenterLogo} from 'components/AvalancheCenterLogo'; -import {Dropdown} from 'components/content/Dropdown'; import {incompleteQueryState, NotFound, QueryState} from 'components/content/QueryState'; -import {HStack, View, VStack} from 'components/core'; +import {View} from 'components/core'; import {AvalancheTab} from 'components/forecast/AvalancheTab'; import {ObservationsTab} from 'components/forecast/ObservationsTab'; import {SynopsisTab} from 'components/forecast/SynopsisTab'; import {WeatherTab} from 'components/forecast/WeatherTab'; import {Body, BodySemibold} from 'components/text'; import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata'; -import {uniq} from 'lodash'; -import {LoggerContext, LoggerProps} from 'loggerContext'; -import {ForecastTabNavigatorParamList, HomeStackNavigationProps, HomeStackParamList} from 'routes'; +import {ForecastTabNavigatorParamList, MainStackNavigationProps, MainStackParamList} from 'routes'; import {colorLookup} from 'theme'; import {AvalancheCenterID, AvalancheForecastZone, AvalancheForecastZoneStatus} from 'types/nationalAvalancheCenter'; import {NotFoundError} from 'types/requests'; import {RequestedTimeString} from 'utils/date'; -export const AvalancheForecast: React.FunctionComponent<{ +export const ForecastScreen = ({route}: NativeStackScreenProps) => { + const {center_id, forecast_zone_id, requestedTime} = route.params; + return ( + // hat tip to https://github.com/react-navigation/react-navigation/issues/8694 for the use of `edges` + + + + ); +}; + +const AvalancheForecast: React.FunctionComponent<{ center_id: AvalancheCenterID; requestedTime: RequestedTimeString; forecast_zone_id: number; }> = ({center_id, requestedTime: requestedTimeString, forecast_zone_id}) => { - const {logger} = React.useContext(LoggerContext); const centerResult = useAvalancheCenterMetadata(center_id); const center = centerResult.data; const Tab = createMaterialTopTabNavigator(); - const navigation = useNavigation(); - const onZoneChange = useCallback( - (zoneName: string) => { - if (center) { - const zone = center?.zones.find(z => z.name === zoneName && z.status === AvalancheForecastZoneStatus.Active); - if (!zone) { - logger.warn({zone: zoneName}, 'zone change callback called with zone not belonging to the center'); - return; - } - setTimeout( - // entirely unclear why this needs to be in a setTimeout, but the app crashes without it - // https://github.com/react-navigation/react-navigation/issues/11201 - () => - navigation.replace('forecast', { - center_id: center_id, - forecast_zone_id: zone.id, - requestedTime: requestedTimeString, - }), - 0, - ); - } - }, - [navigation, center, center_id, requestedTimeString, logger], - ); + const navigation = useNavigation(); - const onReturnToMapView = useCallback(() => { - navigation.popToTop(); - }, [navigation]); + const zone: AvalancheForecastZone | undefined = useMemo(() => center?.zones.find(item => item.id === forecast_zone_id), [center?.zones, forecast_zone_id]); + + React.useEffect(() => { + if (zone?.name) { + navigation.setOptions({title: `${zone.name}`}); + } + }, [navigation, zone?.name]); if (incompleteQueryState(centerResult) || !center) { return ; } - const zone: AvalancheForecastZone | undefined = center.zones.find(item => item.id === forecast_zone_id); if (!zone || zone.status === AvalancheForecastZoneStatus.Disabled) { const message = `Avalanche center ${center_id} had no zone with id ${forecast_zone_id}`; if (!zone) { @@ -79,107 +64,55 @@ export const AvalancheForecast: React.FunctionComponent<{ return ; } - const zones = uniq(center.zones.filter(z => z.status === AvalancheForecastZoneStatus.Active).map(z => z.name)); - return ( - - - - - - - - - {zones.length > 1 ? ( - - ) : ( - - {zones[0]} - - )} - - - + + }} + /> + }} + /> + , + }} + /> + {process.env.EXPO_PUBLIC_ENABLE_CONDITIONS_BLOG && center.config.blog && center.config.blog_title && ( }} - /> - }} - /> - , + tabBarLabel: ({focused, color}) => , }} /> - {process.env.EXPO_PUBLIC_ENABLE_CONDITIONS_BLOG && center.config.blog && center.config.blog_title && ( - , - }} - /> - )} - - - ); -}; - -const TabLabel: React.FC<{title: string; focused: boolean; color: string}> = ({title, focused, color}) => { - // the tab view library has a long-standing bug where they don't re-render tabs during focus, - // so we resort to always rendering the focused (larger) text in order to ensure it does not - // get cut off, ref: - // https://github.com/satya164/react-native-tab-view/issues/992 - return ( - - {focused ? ( - - {title} - - ) : ( - - {title} - )} - - - {title} - - - - ); -}; -export const ForecastScreen = ({route}: NativeStackScreenProps) => { - const {center_id, forecast_zone_id, requestedTime} = route.params; - return ( - // hat tip to https://github.com/react-navigation/react-navigation/issues/8694 for the use of `edges` - - - + ); }; -export const AvalancheTabScreen = ({route}: MaterialTopTabScreenProps) => { +// MARK: Tab Screens + +const AvalancheTabScreen = ({route}: MaterialTopTabScreenProps) => { const {center_id, forecast_zone_id, requestedTime} = route.params; return ( @@ -188,7 +121,7 @@ export const AvalancheTabScreen = ({route}: MaterialTopTabScreenProps) => { +const WeatherTabScreen = ({route}: MaterialTopTabScreenProps) => { const {center_id, forecast_zone_id, requestedTime} = route.params; return ( @@ -197,7 +130,7 @@ export const WeatherTabScreen = ({route}: MaterialTopTabScreenProps) => { +const ObservationsTabScreen = ({route}: MaterialTopTabScreenProps) => { const {center_id, forecast_zone_id, requestedTime} = route.params; return ( @@ -206,7 +139,7 @@ export const ObservationsTabScreen = ({route}: MaterialTopTabScreenProps) => { +const SynopsisTabScreen = ({route}: MaterialTopTabScreenProps) => { const {center_id, forecast_zone_id, requestedTime} = route.params; return ( @@ -214,3 +147,30 @@ export const SynopsisTabScreen = ({route}: MaterialTopTabScreenProps ); }; + +// MARK: Tab Label Component + +const TabLabel: React.FC<{title: string; focused: boolean; color: string}> = ({title, focused, color}) => { + // the tab view library has a long-standing bug where they don't re-render tabs during focus, + // so we resort to always rendering the focused (larger) text in order to ensure it does not + // get cut off, ref: + // https://github.com/satya164/react-native-tab-view/issues/992 + return ( + + {focused ? ( + + {title} + + ) : ( + + {title} + + )} + + + {title} + + + + ); +}; diff --git a/components/screens/HomeScreen.tsx b/components/screens/HomeScreen.tsx deleted file mode 100644 index ca4e657a..00000000 --- a/components/screens/HomeScreen.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import {createNativeStackNavigator, NativeStackScreenProps} from '@react-navigation/native-stack'; -import React from 'react'; - -import {NavigationHeader} from 'components/content/NavigationHeader'; -import {ForecastScreen} from 'components/screens/ForecastScreen'; -import {MapScreen} from 'components/screens/MapScreen'; -import {NWACObservationScreen, ObservationScreen, ObservationSubmitScreen} from 'components/screens/ObservationsScreen'; -import {StationDetailScreen, StationsDetailScreen} from 'components/screens/WeatherScreen'; -import {usePreferences} from 'Preferences'; -import {HomeStackParamList, TabNavigatorParamList} from 'routes'; - -const AvalancheCenterStack = createNativeStackNavigator(); -export const HomeTabScreen = ({route}: NativeStackScreenProps) => { - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - }}> - - - }} - /> - }} - /> - - - - - ); -}; diff --git a/components/screens/MapScreen.tsx b/components/screens/MapScreen.tsx deleted file mode 100644 index b82dd5d1..00000000 --- a/components/screens/MapScreen.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, {useEffect} from 'react'; -import {Alert, View} from 'react-native'; - -import * as Updates from 'expo-updates'; - -import {NativeStackScreenProps} from '@react-navigation/native-stack'; - -import {useIsFocused} from '@react-navigation/native'; -import {AvalancheForecastZoneMap} from 'components/AvalancheForecastZoneMap'; -import {useEASUpdateStatus} from 'hooks/useEASUpdateStatus'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {HomeStackParamList} from 'routes'; -import {parseRequestedTimeString} from 'utils/date'; - -export const MapScreen = ({route}: NativeStackScreenProps) => { - const updateStatus = useEASUpdateStatus(); - const isActiveScreen = useIsFocused(); - const safeAreaInset = useSafeAreaInsets(); - - useEffect(() => { - if (updateStatus === 'update-downloaded' && isActiveScreen) { - Alert.alert('Update Available', 'A new version of the app is available. Press OK to apply the update.', [ - { - text: 'OK', - onPress: () => void Updates.reloadAsync(), - }, - ]); - } - }, [isActiveScreen, updateStatus]); - - const {requestedTime} = route.params; - return ( - - - - ); -}; diff --git a/components/screens/MenuScreen.tsx b/components/screens/MenuScreen.tsx deleted file mode 100644 index 5f662161..00000000 --- a/components/screens/MenuScreen.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import React, {useCallback} from 'react'; - -import {ScrollView, StyleSheet} from 'react-native'; -import {SafeAreaView} from 'react-native-safe-area-context'; - -import {AvalancheCenterSelector} from 'components/AvalancheCenterSelector'; - -import {createNativeStackNavigator, NativeStackScreenProps} from '@react-navigation/native-stack'; - -import {RouteProp, useFocusEffect, useNavigation} from '@react-navigation/native'; -import {MenuStackNavigationProps, MenuStackParamList, TabNavigatorParamList} from 'routes'; - -import {View, VStack} from 'components/core'; - -import * as Updates from 'expo-updates'; -import * as WebBrowser from 'expo-web-browser'; - -import {QueryCache} from '@tanstack/react-query'; - -import {AvalancheCenters} from 'components/avalancheCenterList'; -import {ActionList} from 'components/content/ActionList'; -import {Button} from 'components/content/Button'; -import {Card} from 'components/content/Card'; -import {incompleteQueryState, QueryState} from 'components/content/QueryState'; -import {FeatureFlagsDebuggerScreen} from 'components/FeatureFlagsDebugger'; -import {ForecastScreen} from 'components/screens/ForecastScreen'; -import {MapScreen} from 'components/screens/MapScreen'; -import {AboutScreen} from 'components/screens/menu/AboutScreen'; -import { - AvalancheComponentPreview, - ButtonStylePreview, - DeveloperMenu, - ExpoConfigScreen, - OutcomeScreen, - TextStylePreview, - TimeMachine, - ToastPreview, -} from 'components/screens/menu/DeveloperMenu'; -import {getVersionInfoFull} from 'components/screens/menu/Version'; -import {NWACObservationScreen, ObservationScreen} from 'components/screens/ObservationsScreen'; -import {Body, BodyBlack, Title3Black} from 'components/text'; -import {settingsMenuItems} from 'data/settingsMenuItems'; -import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabilities'; -import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata'; -import {getUpdateGroupId} from 'hooks/useEASUpdateStatus'; -import {LoggerContext, LoggerProps} from 'loggerContext'; -import {sendMail} from 'network/sendMail'; -import {usePostHog} from 'posthog-react-native'; -import {usePreferences} from 'Preferences'; -import {colorLookup} from 'theme'; -import {AvalancheCenterID, userFacingCenterId} from 'types/nationalAvalancheCenter'; - -const MenuStack = createNativeStackNavigator(); -export const MenuStackScreen = ( - {route}: NativeStackScreenProps, - queryCache: QueryCache, - staging: boolean, - setStaging: React.Dispatch>, -) => { - const {requestedTime} = route.params; - const {preferences, setPreferences} = usePreferences(); - - const centerId = preferences.center; - const avalancheCenterSelectorOptions = useCallback( - ({route}: {route: RouteProp}) => ({ - headerShown: true, - title: `Select Avalanche Center${route.params.debugMode ? ' (debug)' : ''}`, - }), - [], - ); - - const setAvalancheCenter = useCallback( - (avalancheCenterId: AvalancheCenterID) => { - setPreferences({center: avalancheCenterId}); - }, - [setPreferences], - ); - - return ( - - - - - - - - - - - - - - - - - - ); -}; - -export const MenuScreen = (queryCache: QueryCache, avalancheCenterId: AvalancheCenterID, staging: boolean, setStaging: React.Dispatch>) => { - const MenuScreen = function (_: NativeStackScreenProps) { - const {logger} = React.useContext(LoggerContext); - const navigation = useNavigation(); - const {data} = useAvalancheCenterMetadata(avalancheCenterId); - const menuItems = settingsMenuItems[avalancheCenterId]; - const capabilitiesResult = useAvalancheCenterCapabilities(); - const capabilities = capabilitiesResult.data; - - const { - preferences: {mixpanelUserId}, - } = usePreferences(); - const [updateGroupId] = getUpdateGroupId(); - - const postHog = usePostHog(); - - const recordAnalytics = useCallback(() => { - postHog?.screen('menu'); - }, [postHog]); - useFocusEffect(recordAnalytics); - const sendMailHandler = useCallback( - () => - void sendMail({ - to: 'developer+app-feedback@nwac.us', - subject: 'NWAC app feedback', - footer: `Please do not delete, info below helps with debugging.\n\n ${getVersionInfoFull(mixpanelUserId, updateGroupId)}`, - logger, - }), - [logger, mixpanelUserId, updateGroupId], - ); - - if (incompleteQueryState(capabilitiesResult) || !capabilities) { - return ; - } - - return ( - - {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there */} - - - - More} noDivider> - - {data?.name && `${data.name} `}({userFacingCenterId(avalancheCenterId, capabilities)}) - - - - - - Settings} - bg="white" - pl={16} - actions={[ - { - label: 'Select avalanche center', - data: 'Center', - action: () => { - navigation.navigate('avalancheCenterSelector', {debugMode: false}); - }, - }, - { - label: 'About Avy', - data: 'About', - action: () => { - navigation.navigate('about'); - }, - }, - ]} - /> - {menuItems && menuItems.length > 0 && ( - General} - bg="white" - pl={16} - actions={menuItems.map(item => ({ - label: item.title, - data: item.title, - action: () => { - void WebBrowser.openBrowserAsync(item.url); - }, - }))} - /> - )} - {Updates.channel !== 'release' && } - - - - - ); - }; - MenuScreen.displayName = 'MenuScreen'; - return MenuScreen; -}; - -export const AvalancheCenterSelectorScreen = (centers: AvalancheCenters, avalancheCenterId: AvalancheCenterID, setAvalancheCenter: (center: AvalancheCenterID) => void) => { - const AvalancheCenterSelectorScreen = function (_: NativeStackScreenProps) { - return ; - }; - AvalancheCenterSelectorScreen.displayName = 'AvalancheCenterSelectorScreen'; - return AvalancheCenterSelectorScreen; -}; diff --git a/components/screens/ObservationsScreen.tsx b/components/screens/ObservationsScreen.tsx deleted file mode 100644 index 2bf9fcc7..00000000 --- a/components/screens/ObservationsScreen.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import {createNativeStackNavigator, NativeStackScreenProps} from '@react-navigation/native-stack'; -import {NavigationHeader} from 'components/content/NavigationHeader'; -import {NWACObservationDetailView, ObservationDetailView} from 'components/observations/ObservationDetailView'; -import {ObservationForm} from 'components/observations/ObservationForm'; -import {ObservationsListView} from 'components/observations/ObservationsListView'; -import {ObservationsPortal} from 'components/observations/ObservationsPortal'; -import {usePreferences} from 'Preferences'; -import React from 'react'; -import {StyleSheet, View} from 'react-native'; -import {ObservationsStackParamList, TabNavigatorParamList} from 'routes'; -import {parseRequestedTimeString} from 'utils/date'; - -const ObservationsStack = createNativeStackNavigator(); -export const ObservationsTabScreen = ({route}: NativeStackScreenProps) => { - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - , - }}> - - - - - - - ); -}; - -const ObservationsPortalScreen = ({route}: NativeStackScreenProps) => { - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ; -}; - -export const ObservationSubmitScreen = () => { - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ; -}; - -const ObservationsListScreen = ({route}: NativeStackScreenProps) => { - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - - - - ); -}; - -export const ObservationScreen = ({route}: NativeStackScreenProps) => { - const {id} = route.params; - return ( - - - - ); -}; - -export const NWACObservationScreen = ({route}: NativeStackScreenProps) => { - const {id} = route.params; - return ( - - - - ); -}; - -const styles = StyleSheet.create({ - fullScreen: { - flex: 1, - }, -}); diff --git a/components/screens/WeatherScreen.tsx b/components/screens/WeatherScreen.tsx deleted file mode 100644 index 74de46ea..00000000 --- a/components/screens/WeatherScreen.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; - -import {createNativeStackNavigator, NativeStackScreenProps} from '@react-navigation/native-stack'; - -import {NavigationHeader} from 'components/content/NavigationHeader'; -import {View, VStack} from 'components/core'; -import {WeatherStationDetail} from 'components/weather_data/WeatherStationDetail'; -import {WeatherStationPage} from 'components/weather_data/WeatherStationPage'; -import {WeatherStationsDetail} from 'components/weather_data/WeatherStationsDetail'; -import {usePreferences} from 'Preferences'; -import {Edge, SafeAreaView} from 'react-native-safe-area-context'; -import {TabNavigatorParamList, WeatherStackParamList} from 'routes'; - -const WeatherStack = createNativeStackNavigator(); -export const WeatherScreen = ({route}: NativeStackScreenProps) => { - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - , - }}> - (center_id === 'NWAC' ? : <>)}} - initialParams={{requestedTime}} - /> - - - - ); -}; - -const StationListScreen = ({route}: NativeStackScreenProps) => { - const edges: Edge[] = ['left', 'right']; - const {requestedTime} = route.params; - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - - {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there */} - - - - - - - ); -}; - -export const StationsDetailScreen = ({route}: NativeStackScreenProps) => { - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - - {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there, or top edge since StackHeader is sitting there */} - - - - - ); -}; - -export const StationDetailScreen = ({route}: NativeStackScreenProps) => { - const {preferences} = usePreferences(); - const center_id = preferences.center; - return ( - - {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there, or top edge since StackHeader is sitting there */} - - - - - ); -}; diff --git a/components/screens/menu/AboutScreen.tsx b/components/screens/main/AboutScreen.tsx similarity index 90% rename from components/screens/menu/AboutScreen.tsx rename to components/screens/main/AboutScreen.tsx index 9c07ae24..8478befe 100644 --- a/components/screens/menu/AboutScreen.tsx +++ b/components/screens/main/AboutScreen.tsx @@ -13,14 +13,15 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import {useFocusEffect} from '@react-navigation/native'; import {ActionList} from 'components/content/ActionList'; import {Center, HStack, View, VStack} from 'components/core'; -import {getVersionInfoFull} from 'components/screens/menu/Version'; +import {getVersionInfoFull} from 'components/screens/main/Version'; import {Body, BodyBlack, BodyXSm, Title3Black} from 'components/text'; import {getUpdateGroupId} from 'hooks/useEASUpdateStatus'; import {usePostHog} from 'posthog-react-native'; import {usePreferences} from 'Preferences'; -import {MenuStackParamList} from 'routes'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {MainStackParamList} from 'routes'; -export const AboutScreen = (_: NativeStackScreenProps) => { +export const AboutScreen = (_: NativeStackScreenProps) => { const { preferences: {mixpanelUserId}, } = usePreferences(); @@ -34,6 +35,8 @@ export const AboutScreen = (_: NativeStackScreenProps { postHog?.screen('about'); }, [postHog]); @@ -41,7 +44,7 @@ export const AboutScreen = (_: NativeStackScreenProps - +
About Avy diff --git a/components/screens/main/AvalancheCenterSelectorScreen.tsx b/components/screens/main/AvalancheCenterSelectorScreen.tsx new file mode 100644 index 00000000..15baf980 --- /dev/null +++ b/components/screens/main/AvalancheCenterSelectorScreen.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import {AvalancheCenterSelector} from 'components/AvalancheCenterSelector'; + +import {NativeStackScreenProps} from '@react-navigation/native-stack'; + +import {MainStackParamList} from 'routes'; + +import {AvalancheCenters} from 'components/avalancheCenterList'; + +import {AvalancheCenterID} from 'types/nationalAvalancheCenter'; + +export const AvalancheCenterSelectorScreen = (centers: AvalancheCenters, avalancheCenterId: AvalancheCenterID, setAvalancheCenter: (center: AvalancheCenterID) => void) => { + const AvalancheCenterSelectorScreen = function (_: NativeStackScreenProps) { + return ; + }; + AvalancheCenterSelectorScreen.displayName = 'AvalancheCenterSelectorScreen'; + return AvalancheCenterSelectorScreen; +}; diff --git a/components/screens/main/DeveloperMenu.tsx b/components/screens/main/DeveloperMenu.tsx new file mode 100644 index 00000000..0d767a0d --- /dev/null +++ b/components/screens/main/DeveloperMenu.tsx @@ -0,0 +1,851 @@ +/* eslint-disable react/jsx-no-bind */ +// We normally want to avoid using fat arrow functions in props as it can cause excessive re-rendering, +// but for the debug menu we shouldn't be too worried about it. It never renders in production. + +import React, {ReactNode, useCallback, useMemo, useState} from 'react'; + +import DateTimePicker, {DateTimePickerEvent} from '@react-native-community/datetimepicker'; +import {Platform, ScrollView, SectionList, StyleSheet, Switch, TouchableOpacity} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {NativeStackScreenProps} from '@react-navigation/native-stack'; + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import {useNavigation} from '@react-navigation/native'; +import {MainStackNavigationProps, MainStackParamList} from 'routes'; + +import {Divider, HStack, View, VStack} from 'components/core'; + +import * as Clipboard from 'expo-clipboard'; +import Constants from 'expo-constants'; +import * as FileSystem from 'expo-file-system'; + +import {useQueryClient} from '@tanstack/react-query'; +import {ClientContext} from 'clientContext'; +import {AvalancheProblemSizeLine} from 'components/AvalancheProblemSizeLine'; +import {ActionList} from 'components/content/ActionList'; +import {Button} from 'components/content/Button'; +import {Card} from 'components/content/Card'; +import {ConnectionLost, InternalError, NotFound} from 'components/content/QueryState'; +import {ActionToast, ErrorToast, InfoToast, SuccessToast, WarningToast} from 'components/content/Toast'; +import {getUploader} from 'components/observations/uploader/ObservationsUploader'; +import {Keys} from 'components/screens/main/Keys'; +import {getVersionInfoFull} from 'components/screens/main/Version'; +import { + AllCapsSm, + AllCapsSmBlack, + Body, + BodyBlack, + BodySemibold, + BodySm, + BodySmBlack, + BodySmSemibold, + BodyXSm, + BodyXSmBlack, + BodyXSmMedium, + Caption1, + Caption1Black, + Caption1Semibold, + FeatureTitleBlack, + Title1, + Title1Black, + Title1Semibold, + Title3, + Title3Black, + Title3Semibold, +} from 'components/text'; +import {QUERY_CACHE_ASYNC_STORAGE_KEY} from 'data/asyncStorageKeys'; +import {getUpdateGroupId} from 'hooks/useEASUpdateStatus'; +import {logFilePath, logger} from 'logger'; +import {sendMail} from 'network/sendMail'; +import {usePreferences} from 'Preferences'; +import Toast from 'react-native-toast-message'; +import {colorLookup} from 'theme'; +import {RequestedTime, requestedTimeToUTCDate, toISOStringUTC} from 'utils/date'; + +interface DeveloperMenuProps { + staging: boolean; + setStaging: React.Dispatch>; +} + +export const DeveloperMenuScreen = ({route}: NativeStackScreenProps) => { + const {staging: initialStaging, setStaging} = route.params; + const [staging, setStagingLocal] = useState(initialStaging); + + const handleSetStaging = useCallback( + (value: boolean | ((prev: boolean) => boolean)) => { + setStagingLocal(value); + setStaging(value); + }, + [setStaging], + ); + + return ( + + + + ); +}; + +const DeveloperMenu: React.FC = ({staging, setStaging}) => { + const navigation = useNavigation(); + const queryCache = useQueryClient().getQueryCache(); + const toggleStaging = React.useCallback(() => { + setStaging(!staging); + + logger.info({environment: staging ? 'production' : 'staging'}, 'switching environment'); + }, [staging, setStaging]); + const {preferences, clearPreferences} = usePreferences(); + const [updateGroupId] = useState(getUpdateGroupId()); + + const debugSettingsActions = useMemo( + () => [ + { + label: 'Select avalanche center (debug)', + data: 'Center (debug)', + action: () => { + navigation.navigate('avalancheCenterSelector', {debugMode: true}); + }, + }, + { + label: 'Time machine', + data: 'timeMachine', + action: () => { + navigation.navigate('timeMachine'); + }, + }, + { + label: 'View Expo configuration', + data: 'Expo Configuration', + action: () => { + navigation.navigate('expoConfig'); + }, + }, + { + label: 'Debug Feature Flags', + data: 'Feature Flags', + action: () => { + navigation.navigate('featureFlags'); + }, + }, + ], + [navigation], + ); + + const sentryErrorAction = useMemo( + () => [ + { + label: 'Trigger exception', + data: 'Button Style Preview', + action: () => { + throw new Error('Test error'); + }, + }, + ], + [], + ); + + const designPreviewActions = useMemo( + () => [ + { + label: 'Open button style preview', + data: 'Button Style Preview', + action: () => { + navigation.navigate('buttonStylePreview'); + }, + }, + { + label: 'Open text style preview', + data: 'Text Style Preview', + action: () => { + navigation.navigate('textStylePreview'); + }, + }, + { + label: 'Open toast preview', + data: 'Toast Preview', + action: () => { + navigation.navigate('toastPreview'); + }, + }, + { + label: 'Open avalanche component preview', + action: () => { + navigation.navigate('avalancheComponentPreview'); + }, + data: undefined, + }, + ], + [navigation], + ); + + const screensActions = useMemo( + () => [ + { + label: 'View map layer with active warning', + data: null, + action: () => { + navigation.navigate('avalancheCenter', { + center_id: 'NWAC', + requestedTime: toISOStringUTC(new Date('2024-02-27T15:21:00-0800')), + }); + }, + }, + { + label: 'View map layer with active watch', + data: null, + action: () => { + navigation.navigate('avalancheCenter', { + center_id: 'CBAC', + requestedTime: toISOStringUTC(new Date('2023-03-21T5:21:00-0800')), + }); + }, + }, + { + label: 'View forecast with active warning', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'NWAC', + forecast_zone_id: 1130, + requestedTime: toISOStringUTC(new Date('2023-02-20T5:21:00-0800')), + }); + }, + }, + { + label: 'View forecast with active watch', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'CBAC', + forecast_zone_id: 298, + requestedTime: toISOStringUTC(new Date('2023-03-21T5:21:00-0800')), + }); + }, + }, + { + label: 'View forecast with active special bulletin', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'CBAC', + forecast_zone_id: 298, + requestedTime: toISOStringUTC(new Date('2022-02-25T5:21:00-0800')), + }); + }, + }, + { + label: 'View forecast with synopsis', // TODO(skuznets): move this to BTAC or something that uses blogs still + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'NWAC', + forecast_zone_id: 1130, + requestedTime: toISOStringUTC(new Date('2022-04-10T5:21:00-0800')), + }); + }, + }, + // TODO(skuznets): choose a recent forecast that's a summary + { + label: 'View forecast with standard row/column weather forecast', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'SNFAC', + forecast_zone_id: 714, + requestedTime: toISOStringUTC(new Date('2023-04-13T5:21:00-0800')), + }); + }, + }, + { + label: 'View forecast with custom row/column weather forecast', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'BTAC', + forecast_zone_id: 1329, + requestedTime: toISOStringUTC(new Date('2023-05-01T21:21:00-0000')), + }); + }, + }, + { + label: 'View forecast with another custom row/column weather forecast', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'SAC', + forecast_zone_id: 77, + requestedTime: toISOStringUTC(new Date('2023-04-08T14:21:00-0000')), + }); + }, + }, + { + label: 'View forecast with inline weather forecast', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'SNFAC', + forecast_zone_id: 714, + requestedTime: toISOStringUTC(new Date('2020-04-08T5:21:00-0800')), + }); + }, + }, + { + label: 'View expired forecast', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'NWAC', + forecast_zone_id: 1130, + requestedTime: toISOStringUTC(new Date('2023-02-01T5:21:00-0800')), + }); + }, + }, + { + label: "View a forecast we can't find", + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'NWAC', + forecast_zone_id: 1130, + requestedTime: toISOStringUTC(new Date('2000-01-01T00:00:00-0800')), + }); + }, + }, + { + label: 'View a forecast with videos', + data: null, + action: () => { + navigation.navigate('forecast', { + center_id: 'NWAC', + forecast_zone_id: 1649, + requestedTime: toISOStringUTC(new Date('2025-02-26T5:11:30-0800')), + }); + }, + }, + ], + [navigation], + ); + + const observationsActions = useMemo( + () => [ + { + label: '1: simple', + data: null, + action: () => { + navigation.navigate('observation', { + id: '65450a80-1f57-468b-a51e-8c19789e0fab', + }); + }, + }, + { + label: '2: simple', + data: null, + action: () => { + navigation.navigate('observation', { + id: '249e927f-aa0e-4e93-90fc-c9a54bc480d8', + }); + }, + }, + { + label: '3: simple: icons', + data: null, + action: () => { + navigation.navigate('observation', { + id: '441f400b-56ac-498c-8754-f9d407796a82', + }); + }, + }, + { + label: '4: complex: weather', + data: null, + action: () => { + navigation.navigate('observation', { + id: 'b8d347d1-7597-47be-9247-adc117100a69', + }); + }, + }, + { + label: '5: complex: weather, snowpack', + data: null, + action: () => { + navigation.navigate('observation', { + id: '2d2f37b4-f46b-4ef2-967d-b018d41d0f2d', + }); + }, + }, + { + label: '6: complex: snowpack', + data: null, + action: () => { + navigation.navigate('observation', { + id: '999d1e0c-154e-43f8-b15f-6585eac4d985', + }); + }, + }, + { + label: '7: complex: avalanches', + data: null, + action: () => { + navigation.navigate('observation', { + id: '4b80e7fc-0011-4fdf-8d86-f2534c1d981c', + }); + }, + }, + { + label: '8: complex: avalanches', + data: null, + action: () => { + navigation.navigate('observation', { + id: '5910e9e7-fe6e-46de-af08-9df9be9192e2', + }); + }, + }, + { + label: '9: with video', + data: null, + action: () => { + navigation.navigate('observation', { + id: '7b1f595d-312a-42f8-adb1-2f1886b7802b', + }); + }, + }, + { + label: 'NWAC pro observation with avalanches', + data: null, + action: () => { + navigation.navigate('nwacObservation', { + id: '20312', + }); + }, + }, + ], + [navigation], + ); + + const componentsActions = useMemo( + () => [ + { + label: 'View connection lost outcome', + data: 'Connection Lost', + action: () => { + navigation.navigate('outcome', { + which: 'connection', + }); + }, + }, + { + label: 'View terminal error outcome', + data: 'Terminal Error', + action: () => { + navigation.navigate('outcome', { + which: 'terminal-error', + }); + }, + }, + { + label: 'View retryable error outcome', + data: 'Retryable Error', + action: () => { + navigation.navigate('outcome', { + which: 'retryable-error', + }); + }, + }, + { + label: 'View not found outcome', + data: 'Not Found', + action: () => { + navigation.navigate('outcome', { + which: 'not-found', + }); + }, + }, + ], + [navigation], + ); + + return ( + + Developer Menu}> + + Debug Settings}> + + + + + + + + + + Use staging environment + + + + + Keys}> + + + Observation Uploader}> + + + + + + Sentry}> + + Config + {(() => { + const dsn = process.env.EXPO_PUBLIC_SENTRY_DSN as string; + return ( + + SENTRY_DSN: {dsn ? `${dsn.slice(0, 15)}...` : 'not supplied'} + + ); + })()} + + + + Design Previews} bg="white" pl={16} actions={designPreviewActions} /> + Screens} bg="white" pl={16} actions={screensActions} /> + Observations} bg="white" pl={16} actions={observationsActions} /> + Components} bg="white" pl={16} actions={componentsActions} /> + + + + ); +}; + +export const ButtonStylePreview = () => ( + + + + + + + + +); + +export const TextStylePreview = () => { + const data = [ + { + title: 'Feature title', + data: [{Component: FeatureTitleBlack, content: 'Feature Title Black'}], + }, + { + title: 'Title 1', + data: [ + {Component: Title1, content: 'Title 1 Regular'}, + {Component: Title1Semibold, content: 'Title 1 Semibold'}, + {Component: Title1Black, content: 'Title 1 Black'}, + ], + }, + { + title: 'Title 3', + data: [ + {Component: Title3, content: 'Title 3 Regular'}, + {Component: Title3Semibold, content: 'Title 3 Semibold'}, + {Component: Title3Black, content: 'Title 3 Black'}, + ], + }, + { + title: 'Body', + data: [ + {Component: Body, content: 'Body Regular'}, + {Component: BodySemibold, content: 'Body Semibold'}, + {Component: BodyBlack, content: 'Body Black'}, + ], + }, + { + title: 'Body Small', + data: [ + {Component: BodySm, content: 'Body small Regular'}, + {Component: BodySmSemibold, content: 'Body small Semibold'}, + {Component: BodySmBlack, content: 'Body small Black'}, + ], + }, + { + title: 'Body Extra Small', + data: [ + {Component: BodyXSm, content: 'Body xsml Regular'}, + {Component: BodyXSmMedium, content: 'Body xsml Medium'}, + {Component: BodyXSmBlack, content: 'Body xsml Black'}, + ], + }, + { + title: 'All Caps Small', + data: [ + {Component: AllCapsSm, content: 'All caps small medium'}, + {Component: AllCapsSmBlack, content: 'All caps small Black'}, + ], + }, + { + title: 'Caption', + data: [ + {Component: Caption1, content: 'Caption 1 Regular'}, + {Component: Caption1Semibold, content: 'Caption 1 Semibold'}, + {Component: Caption1Black, content: 'Caption 1 Black'}, + ], + }, + ]; + return ( + + item.content} + renderItem={({item}) => {item.content}} + renderSectionHeader={() => } + /> + + ); +}; + +export const ToastPreview = () => { + return ( + + + + Toast.show({ + type: 'success', + text1: 'Thank you for your submission', + position: 'bottom', + }) + }> + + + + Toast.show({ + type: 'info', + text1: 'Informational content here', + position: 'bottom', + }) + }> + + + + Toast.show({ + type: 'action', + text1: 'You must complete...', + position: 'bottom', + }) + }> + + + + Toast.show({ + type: 'error', + text1: 'This forecast has expired...', + position: 'bottom', + }) + }> + + + + Toast.show({ + type: 'error', + text1: 'Persistent toast', + position: 'bottom', + autoHide: false, + onPress: () => Toast.hide(), + }) + } + /> + + Toast.show({ + type: 'warning', + text1: 'Could not fetch...', + position: 'bottom', + }) + }> + + + + Toast.show({ + type: 'error', + text1: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt...', + position: 'bottom', + }) + }> + + + + + ); +}; + +export const AvalancheComponentPreview = () => { + return ( + + + {[ + [1, 1], + [1, 1.5], + [1, 2], + [1.5, 2.5], + [2, 3], + [2, 4], + [2, 5], + ].map((size, index) => ( + + + + + D{size[0]} - D{size[1]} + + + + ))} + + + ); +}; + +export const TimeMachine = () => { + const {requestedTime, setRequestedTime} = React.useContext(ClientContext); + const changeTime = useCallback( + (time: RequestedTime) => { + setRequestedTime(time); + }, + [setRequestedTime], + ); + const onDateSelected = useCallback( + (event: DateTimePickerEvent, date?: Date) => { + if (event.type === 'set') { + changeTime(date || 'latest'); + } + }, + [changeTime], + ); + return ( + + + + + Other interesting days + + + + ); +}; + +export const OutcomeScreen = ({route}: NativeStackScreenProps) => { + const {which} = route.params; + let outcome: ReactNode = ; + switch (which) { + case 'connection': + outcome = ; + break; + case 'terminal-error': + outcome = ; + break; + case 'retryable-error': + outcome = ; + break; + case 'not-found': + outcome = ; + break; + } + return ( + + {outcome} + + ); +}; + +export const ExpoConfigScreen = (_: NativeStackScreenProps) => { + return ( + + + Expo Configuration}> + {JSON.stringify(Constants.expoConfig, null, 2)} + + + + ); +}; + +const styles = StyleSheet.create({ + fullscreen: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/components/screens/menu/Keys.tsx b/components/screens/main/Keys.tsx similarity index 100% rename from components/screens/menu/Keys.tsx rename to components/screens/main/Keys.tsx diff --git a/components/screens/menu/Version.ts b/components/screens/main/Version.ts similarity index 100% rename from components/screens/menu/Version.ts rename to components/screens/main/Version.ts diff --git a/components/screens/menu/DeveloperMenu.tsx b/components/screens/menu/DeveloperMenu.tsx deleted file mode 100644 index e444a1b1..00000000 --- a/components/screens/menu/DeveloperMenu.tsx +++ /dev/null @@ -1,832 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ -// We normally want to avoid using fat arrow functions in props as it can cause excessive re-rendering, -// but for the debug menu we shouldn't be too worried about it. It never renders in production. - -import React, {ReactNode, useCallback, useState} from 'react'; - -import DateTimePicker, {DateTimePickerEvent} from '@react-native-community/datetimepicker'; -import {Platform, ScrollView, SectionList, StyleSheet, Switch, TouchableOpacity} from 'react-native'; -import {SafeAreaView} from 'react-native-safe-area-context'; - -import {NativeStackScreenProps} from '@react-navigation/native-stack'; - -import AsyncStorage from '@react-native-async-storage/async-storage'; -import {useNavigation} from '@react-navigation/native'; -import {MenuStackNavigationProps, MenuStackParamList, TabNavigationProps} from 'routes'; - -import {Divider, HStack, View, VStack} from 'components/core'; - -import * as Clipboard from 'expo-clipboard'; -import Constants from 'expo-constants'; -import * as FileSystem from 'expo-file-system'; - -import {useQueryClient} from '@tanstack/react-query'; -import {ClientContext} from 'clientContext'; -import {AvalancheProblemSizeLine} from 'components/AvalancheProblemSizeLine'; -import {ActionList} from 'components/content/ActionList'; -import {Button} from 'components/content/Button'; -import {Card} from 'components/content/Card'; -import {ConnectionLost, InternalError, NotFound} from 'components/content/QueryState'; -import {ActionToast, ErrorToast, InfoToast, SuccessToast, WarningToast} from 'components/content/Toast'; -import {getUploader} from 'components/observations/uploader/ObservationsUploader'; -import {Keys} from 'components/screens/menu/Keys'; -import {getVersionInfoFull} from 'components/screens/menu/Version'; -import { - AllCapsSm, - AllCapsSmBlack, - Body, - BodyBlack, - BodySemibold, - BodySm, - BodySmBlack, - BodySmSemibold, - BodyXSm, - BodyXSmBlack, - BodyXSmMedium, - Caption1, - Caption1Black, - Caption1Semibold, - FeatureTitleBlack, - Title1, - Title1Black, - Title1Semibold, - Title3, - Title3Black, - Title3Semibold, -} from 'components/text'; -import {QUERY_CACHE_ASYNC_STORAGE_KEY} from 'data/asyncStorageKeys'; -import {getUpdateGroupId} from 'hooks/useEASUpdateStatus'; -import {logFilePath, logger} from 'logger'; -import {sendMail} from 'network/sendMail'; -import {usePreferences} from 'Preferences'; -import Toast from 'react-native-toast-message'; -import {colorLookup} from 'theme'; -import {RequestedTime, requestedTimeToUTCDate, toISOStringUTC} from 'utils/date'; - -interface DeveloperMenuProps { - staging: boolean; - setStaging: React.Dispatch>; -} - -export const DeveloperMenu: React.FC = ({staging, setStaging}) => { - const navigation = useNavigation(); - const queryCache = useQueryClient().getQueryCache(); - const toggleStaging = React.useCallback(() => { - setStaging(!staging); - - logger.info({environment: staging ? 'production' : 'staging'}, 'switching environment'); - }, [staging, setStaging]); - const {preferences, clearPreferences} = usePreferences(); - const [updateGroupId] = useState(getUpdateGroupId()); - return ( - Developer Menu}> - - Debug Settings}> - - { - navigation.navigate('avalancheCenterSelector', {debugMode: true}); - }, - }, - { - label: 'Time machine', - data: 'timeMachine', - action: () => { - navigation.navigate('timeMachine'); - }, - }, - { - label: 'View Expo configuration', - data: 'Expo Configuration', - action: () => { - navigation.navigate('expoConfig'); - }, - }, - { - label: 'Debug Feature Flags', - data: 'Feature Flags', - action: () => { - navigation.navigate('featureFlags'); - }, - }, - ]} - /> - - - - - - - - Use staging environment - - - - - Keys}> - - - Observation Uploader}> - - - - - - Sentry}> - - Config - {(() => { - const dsn = process.env.EXPO_PUBLIC_SENTRY_DSN as string; - return ( - - SENTRY_DSN: {dsn ? `${dsn.slice(0, 15)}...` : 'not supplied'} - - ); - })()} - { - throw new Error('Test error'); - }, - }, - ]} - /> - - - Design Previews} - bg="white" - pl={16} - actions={[ - { - label: 'Open button style preview', - data: 'Button Style Preview', - action: () => { - navigation.navigate('buttonStylePreview'); - }, - }, - { - label: 'Open text style preview', - data: 'Text Style Preview', - action: () => { - navigation.navigate('textStylePreview'); - }, - }, - { - label: 'Open toast preview', - data: 'Toast Preview', - action: () => { - navigation.navigate('toastPreview'); - }, - }, - { - label: 'Open avalanche component preview', - action: () => { - navigation.navigate('avalancheComponentPreview'); - }, - data: undefined, - }, - ]} - /> - Screens} - bg="white" - pl={16} - actions={[ - { - label: 'View map layer with active warning', - data: null, - action: () => { - navigation.navigate('avalancheCenter', { - center_id: 'NWAC', - requestedTime: toISOStringUTC(new Date('2024-02-27T15:21:00-0800')), - }); - }, - }, - { - label: 'View map layer with active watch', - data: null, - action: () => { - navigation.navigate('avalancheCenter', { - center_id: 'CBAC', - requestedTime: toISOStringUTC(new Date('2023-03-21T5:21:00-0800')), - }); - }, - }, - { - label: 'View forecast with active warning', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'NWAC', - forecast_zone_id: 1130, - requestedTime: toISOStringUTC(new Date('2023-02-20T5:21:00-0800')), - }); - }, - }, - { - label: 'View forecast with active watch', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'CBAC', - forecast_zone_id: 298, - requestedTime: toISOStringUTC(new Date('2023-03-21T5:21:00-0800')), - }); - }, - }, - { - label: 'View forecast with active special bulletin', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'CBAC', - forecast_zone_id: 298, - requestedTime: toISOStringUTC(new Date('2022-02-25T5:21:00-0800')), - }); - }, - }, - { - label: 'View forecast with synopsis', // TODO(skuznets): move this to BTAC or something that uses blogs still - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'NWAC', - forecast_zone_id: 1130, - requestedTime: toISOStringUTC(new Date('2022-04-10T5:21:00-0800')), - }); - }, - }, - // TODO(skuznets): choose a recent forecast that's a summary - { - label: 'View forecast with standard row/column weather forecast', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'SNFAC', - forecast_zone_id: 714, - requestedTime: toISOStringUTC(new Date('2023-04-13T5:21:00-0800')), - }); - }, - }, - { - label: 'View forecast with custom row/column weather forecast', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'BTAC', - forecast_zone_id: 1329, - requestedTime: toISOStringUTC(new Date('2023-05-01T21:21:00-0000')), - }); - }, - }, - { - label: 'View forecast with another custom row/column weather forecast', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'SAC', - forecast_zone_id: 77, - requestedTime: toISOStringUTC(new Date('2023-04-08T14:21:00-0000')), - }); - }, - }, - { - label: 'View forecast with inline weather forecast', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'SNFAC', - forecast_zone_id: 714, - requestedTime: toISOStringUTC(new Date('2020-04-08T5:21:00-0800')), - }); - }, - }, - { - label: 'View expired forecast', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'NWAC', - forecast_zone_id: 1130, - requestedTime: toISOStringUTC(new Date('2023-02-01T5:21:00-0800')), - }); - }, - }, - { - label: "View a forecast we can't find", - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'NWAC', - forecast_zone_id: 1130, - requestedTime: toISOStringUTC(new Date('2000-01-01T00:00:00-0800')), - }); - }, - }, - { - label: 'View a forecast with videos', - data: null, - action: () => { - navigation.navigate('forecast', { - center_id: 'NWAC', - forecast_zone_id: 1649, - requestedTime: toISOStringUTC(new Date('2025-02-26T5:11:30-0800')), - }); - }, - }, - ]} - /> - Observations} - bg="white" - pl={16} - actions={[ - { - label: '1: simple', - data: null, - action: () => { - navigation.navigate('observation', { - id: '65450a80-1f57-468b-a51e-8c19789e0fab', - }); - }, - }, - { - label: '2: simple', - data: null, - action: () => { - navigation.navigate('observation', { - id: '249e927f-aa0e-4e93-90fc-c9a54bc480d8', - }); - }, - }, - { - label: '3: simple: icons', - data: null, - action: () => { - navigation.navigate('observation', { - id: '441f400b-56ac-498c-8754-f9d407796a82', - }); - }, - }, - { - label: '4: complex: weather', - data: null, - action: () => { - navigation.navigate('observation', { - id: 'b8d347d1-7597-47be-9247-adc117100a69', - }); - }, - }, - { - label: '5: complex: weather, snowpack', - data: null, - action: () => { - navigation.navigate('observation', { - id: '2d2f37b4-f46b-4ef2-967d-b018d41d0f2d', - }); - }, - }, - { - label: '6: complex: snowpack', - data: null, - action: () => { - navigation.navigate('observation', { - id: '999d1e0c-154e-43f8-b15f-6585eac4d985', - }); - }, - }, - { - label: '7: complex: avalanches', - data: null, - action: () => { - navigation.navigate('observation', { - id: '4b80e7fc-0011-4fdf-8d86-f2534c1d981c', - }); - }, - }, - { - label: '8: complex: avalanches', - data: null, - action: () => { - navigation.navigate('observation', { - id: '5910e9e7-fe6e-46de-af08-9df9be9192e2', - }); - }, - }, - { - label: '9: with video', - data: null, - action: () => { - navigation.navigate('observation', { - id: '7b1f595d-312a-42f8-adb1-2f1886b7802b', - }); - }, - }, - { - label: 'NWAC pro observation with avalanches', - data: null, - action: () => { - navigation.navigate('nwacObservation', { - id: '20312', - }); - }, - }, - ]} - /> - Components} - bg="white" - pl={16} - actions={[ - { - label: 'View connection lost outcome', - data: 'Connection Lost', - action: () => { - navigation.navigate('outcome', { - which: 'connection', - }); - }, - }, - { - label: 'View terminal error outcome', - data: 'Terminal Error', - action: () => { - navigation.navigate('outcome', { - which: 'terminal-error', - }); - }, - }, - { - label: 'View retryable error outcome', - data: 'Retryable Error', - action: () => { - navigation.navigate('outcome', { - which: 'retryable-error', - }); - }, - }, - { - label: 'View not found outcome', - data: 'Not Found', - action: () => { - navigation.navigate('outcome', { - which: 'not-found', - }); - }, - }, - ]} - /> - - - ); -}; - -export const ButtonStylePreview = () => ( - - - - - - - - -); - -export const TextStylePreview = () => { - const data = [ - { - title: 'Feature title', - data: [{Component: FeatureTitleBlack, content: 'Feature Title Black'}], - }, - { - title: 'Title 1', - data: [ - {Component: Title1, content: 'Title 1 Regular'}, - {Component: Title1Semibold, content: 'Title 1 Semibold'}, - {Component: Title1Black, content: 'Title 1 Black'}, - ], - }, - { - title: 'Title 3', - data: [ - {Component: Title3, content: 'Title 3 Regular'}, - {Component: Title3Semibold, content: 'Title 3 Semibold'}, - {Component: Title3Black, content: 'Title 3 Black'}, - ], - }, - { - title: 'Body', - data: [ - {Component: Body, content: 'Body Regular'}, - {Component: BodySemibold, content: 'Body Semibold'}, - {Component: BodyBlack, content: 'Body Black'}, - ], - }, - { - title: 'Body Small', - data: [ - {Component: BodySm, content: 'Body small Regular'}, - {Component: BodySmSemibold, content: 'Body small Semibold'}, - {Component: BodySmBlack, content: 'Body small Black'}, - ], - }, - { - title: 'Body Extra Small', - data: [ - {Component: BodyXSm, content: 'Body xsml Regular'}, - {Component: BodyXSmMedium, content: 'Body xsml Medium'}, - {Component: BodyXSmBlack, content: 'Body xsml Black'}, - ], - }, - { - title: 'All Caps Small', - data: [ - {Component: AllCapsSm, content: 'All caps small medium'}, - {Component: AllCapsSmBlack, content: 'All caps small Black'}, - ], - }, - { - title: 'Caption', - data: [ - {Component: Caption1, content: 'Caption 1 Regular'}, - {Component: Caption1Semibold, content: 'Caption 1 Semibold'}, - {Component: Caption1Black, content: 'Caption 1 Black'}, - ], - }, - ]; - return ( - - item.content} - renderItem={({item}) => {item.content}} - renderSectionHeader={() => } - /> - - ); -}; - -export const ToastPreview = () => { - return ( - - - - Toast.show({ - type: 'success', - text1: 'Thank you for your submission', - position: 'bottom', - }) - }> - - - - Toast.show({ - type: 'info', - text1: 'Informational content here', - position: 'bottom', - }) - }> - - - - Toast.show({ - type: 'action', - text1: 'You must complete...', - position: 'bottom', - }) - }> - - - - Toast.show({ - type: 'error', - text1: 'This forecast has expired...', - position: 'bottom', - }) - }> - - - - Toast.show({ - type: 'error', - text1: 'Persistent toast', - position: 'bottom', - autoHide: false, - onPress: () => Toast.hide(), - }) - } - /> - - Toast.show({ - type: 'warning', - text1: 'Could not fetch...', - position: 'bottom', - }) - }> - - - - Toast.show({ - type: 'error', - text1: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt...', - position: 'bottom', - }) - }> - - - - - ); -}; - -export const AvalancheComponentPreview = () => { - return ( - - - {[ - [1, 1], - [1, 1.5], - [1, 2], - [1.5, 2.5], - [2, 3], - [2, 4], - [2, 5], - ].map((size, index) => ( - - - - - D{size[0]} - D{size[1]} - - - - ))} - - - ); -}; - -export const TimeMachine = () => { - const navigation = useNavigation(); - const {requestedTime, setRequestedTime} = React.useContext(ClientContext); - const changeTime = useCallback( - (time: RequestedTime) => { - setRequestedTime(time); - // We need to clear navigation state to force all screens from the - // previous time to unmount - navigation.reset({ - index: 0, - routes: [{name: 'Home'}], - }); - }, - [navigation, setRequestedTime], - ); - const onDateSelected = useCallback( - (event: DateTimePickerEvent, date?: Date) => { - if (event.type === 'set') { - changeTime(date || 'latest'); - } - }, - [changeTime], - ); - return ( - - - - - Other interesting days - - - - ); -}; - -export const OutcomeScreen = ({route}: NativeStackScreenProps) => { - const {which} = route.params; - let outcome: ReactNode = ; - switch (which) { - case 'connection': - outcome = ; - break; - case 'terminal-error': - outcome = ; - break; - case 'retryable-error': - outcome = ; - break; - case 'not-found': - outcome = ; - break; - } - return ( - - {outcome} - - ); -}; - -export const ExpoConfigScreen = (_: NativeStackScreenProps) => { - return ( - - - Expo Configuration}> - {JSON.stringify(Constants.expoConfig, null, 2)} - - - - ); -}; - -const styles = StyleSheet.create({ - fullscreen: { - ...StyleSheet.absoluteFillObject, - }, -}); diff --git a/components/screens/navigation/BottomTabs.tsx b/components/screens/navigation/BottomTabs.tsx new file mode 100644 index 00000000..aabe80e6 --- /dev/null +++ b/components/screens/navigation/BottomTabs.tsx @@ -0,0 +1,149 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import {BottomTabHeaderProps, createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import {RouteProp, useIsFocused} from '@react-navigation/native'; +import {NativeStackScreenProps} from '@react-navigation/native-stack'; +import {AvalancheForecastZoneMap} from 'components/AvalancheForecastZoneMap'; +import {BottomTabNavigationHeader} from 'components/content/BottomTabNavigationHeader'; +import {View, VStack} from 'components/core'; +import {ObservationsListView} from 'components/observations/ObservationsListView'; +import {WeatherStationPage} from 'components/weather_data/WeatherStationPage'; +import * as Updates from 'expo-updates'; +import {useEASUpdateStatus} from 'hooks/useEASUpdateStatus'; +import {merge} from 'lodash'; +import {usePreferences} from 'Preferences'; +import React, {useCallback, useEffect} from 'react'; +import {Alert, StyleSheet} from 'react-native'; +import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {TabNavigatorParamList} from 'routes'; +import {colorLookup} from 'theme'; +import {formatRequestedTime, parseRequestedTimeString, RequestedTime} from 'utils/date'; + +const BottomTabNavigator = createBottomTabNavigator(); +export const BottomTabs: React.FunctionComponent<{requestedTime: RequestedTime}> = ({requestedTime}) => { + const renderHeader = useCallback((props: BottomTabHeaderProps) => , []); + + const {preferences} = usePreferences(); + const centerId = preferences.center; + + const tabNavigatorScreenOptions = useCallback( + ({route: {name}}: {route: RouteProp}) => ({ + tabBarIcon: ({color, size}: {focused: boolean; color: string; size: number}) => { + if (name === 'Map') { + return ; + } else if (name === 'Observations') { + return ; + } else if (name === 'Weather') { + return ; + } + }, + // these two properties should really take ColorValue but oh well + tabBarActiveTintColor: colorLookup('primary') as string, + tabBarInactiveTintColor: colorLookup('text.secondary') as string, + header: renderHeader, + }), + [renderHeader], + ); + return ( + + + {state => + MapScreen( + merge(state, { + route: { + params: { + center_id: centerId, + requestedTime: formatRequestedTime(requestedTime), + }, + }, + }), + ) + } + + + {state => + ObservationsListScreen( + merge(state, { + route: { + params: { + center_id: centerId, + requestedTime: formatRequestedTime(requestedTime), + }, + }, + }), + ) + } + + + {state => + WeatherStationListScreen( + merge(state, { + route: { + params: { + center_id: centerId, + requestedTime: formatRequestedTime(requestedTime), + }, + }, + }), + ) + } + + + ); +}; + +// MARK: Bottom Tab Screens + +const MapScreen = ({route}: NativeStackScreenProps) => { + const updateStatus = useEASUpdateStatus(); + const isActiveScreen = useIsFocused(); + const safeAreaInset = useSafeAreaInsets(); + + useEffect(() => { + if (updateStatus === 'update-downloaded' && isActiveScreen) { + Alert.alert('Update Available', 'A new version of the app is available. Press OK to apply the update.', [ + { + text: 'OK', + onPress: () => void Updates.reloadAsync(), + }, + ]); + } + }, [isActiveScreen, updateStatus]); + + const {center_id, requestedTime} = route.params; + return ( + + + + ); +}; + +const ObservationsListScreen = ({route}: NativeStackScreenProps) => { + const {center_id, requestedTime} = route.params; + return ( + + + + ); +}; + +const WeatherStationListScreen = ({route}: NativeStackScreenProps) => { + const edges: Edge[] = ['left', 'right']; + const {center_id, requestedTime} = route.params; + + return ( + + {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there */} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + fullScreen: { + flex: 1, + }, +}); diff --git a/components/screens/navigation/Drawer.tsx b/components/screens/navigation/Drawer.tsx new file mode 100644 index 00000000..31112c50 --- /dev/null +++ b/components/screens/navigation/Drawer.tsx @@ -0,0 +1,165 @@ +import {createDrawerNavigator, DrawerContentComponentProps} from '@react-navigation/drawer'; +import {RouteProp, useFocusEffect} from '@react-navigation/native'; +import {AvalancheCenterLogo} from 'components/AvalancheCenterLogo'; +import {ActionList} from 'components/content/ActionList'; +import {Button} from 'components/content/Button'; +import {incompleteQueryState, QueryState} from 'components/content/QueryState'; +import {HStack, View, VStack} from 'components/core'; +import {getVersionInfoFull} from 'components/screens/main/Version'; +import {MainStackNavigator} from 'components/screens/navigation/MainStack'; +import {BodyBlack, Title3Semibold} from 'components/text'; +import {settingsMenuItems} from 'data/settingsMenuItems'; +import * as Updates from 'expo-updates'; +import * as WebBrowser from 'expo-web-browser'; +import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabilities'; +import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata'; +import {getUpdateGroupId} from 'hooks/useEASUpdateStatus'; +import {LoggerContext, LoggerProps} from 'loggerContext'; +import {sendMail} from 'network/sendMail'; +import {usePostHog} from 'posthog-react-native'; +import {usePreferences} from 'Preferences'; +import React, {useCallback, useMemo} from 'react'; +import {ScrollView, StyleSheet} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {DrawerParamList} from 'routes'; +import {colorLookup} from 'theme'; +import {AvalancheCenterID} from 'types/nationalAvalancheCenter'; +import {RequestedTime} from 'utils/date'; + +const Drawer = createDrawerNavigator(); +export const DrawerNavigator: React.FunctionComponent<{ + requestedTime: RequestedTime; + centerId: AvalancheCenterID; + staging: boolean; + setStaging: React.Dispatch>; +}> = ({requestedTime, centerId, staging, setStaging}) => { + const renderDrawer = useCallback( + (props: DrawerContentComponentProps) => , + [centerId, staging, setStaging], + ); + + const renderMainStack = useCallback( + (_: {route: RouteProp}) => , + [requestedTime, centerId, staging, setStaging], + ); + return ( + + + {renderMainStack} + + + ); +}; + +const DrawerMenu: React.FunctionComponent< + DrawerContentComponentProps & {avalancheCenterId: AvalancheCenterID; staging: boolean; setStaging: React.Dispatch>} +> = ({navigation, avalancheCenterId, staging, setStaging}) => { + const {logger} = React.useContext(LoggerContext); + + const {data} = useAvalancheCenterMetadata(avalancheCenterId); + const menuItems = settingsMenuItems[avalancheCenterId]; + const capabilitiesResult = useAvalancheCenterCapabilities(); + const capabilities = capabilitiesResult.data; + + const { + preferences: {mixpanelUserId}, + } = usePreferences(); + const [updateGroupId] = getUpdateGroupId(); + + const postHog = usePostHog(); + + const recordAnalytics = useCallback(() => { + postHog?.screen('menu'); + }, [postHog]); + useFocusEffect(recordAnalytics); + const sendMailHandler = useCallback( + () => + void sendMail({ + to: 'developer+app-feedback@nwac.us', + subject: 'NWAC app feedback', + footer: `Please do not delete, info below helps with debugging.\n\n ${getVersionInfoFull(mixpanelUserId, updateGroupId)}`, + logger, + }), + [logger, mixpanelUserId, updateGroupId], + ); + + const navigateToCenterSelection = useCallback(() => { + navigation.navigate('MainStack', {screen: 'avalancheCenterSelector', params: {debugMode: false}}); + }, [navigation]); + + const navigateToAbout = useCallback(() => { + navigation.navigate('MainStack', {screen: 'about'}); + }, [navigation]); + + const navigateToDeveloperMenu = useCallback(() => { + navigation.navigate('MainStack', {screen: 'developerMenu', params: {staging: staging, setStaging: setStaging}}); + }, [navigation, staging, setStaging]); + + const menuActions = useMemo( + () => + menuItems.map(item => ({ + label: item.title, + data: item.title, + action: () => { + void WebBrowser.openBrowserAsync(item.url); + }, + })), + [menuItems], + ); + + if (incompleteQueryState(capabilitiesResult) || !capabilities) { + return ; + } + + return ( + + + + + + {data?.name && data.name} + + + + + + {menuItems && menuItems.length > 0 && General} bg="white" pl={16} actions={menuActions} />} + Settings} + bg="white" + pl={16} + actions={[ + { + label: 'Select avalanche center', + data: 'Center', + action: navigateToCenterSelection, + }, + { + label: 'About Avy', + data: 'About', + action: navigateToAbout, + }, + ]} + /> + {Updates.channel !== 'release' && ( + Developer Menu} + bg="white" + pl={16} + actions={[ + { + label: 'Open Dev Menu', + data: '', + action: navigateToDeveloperMenu, + }, + ]} + /> + )} + + + + + ); +}; diff --git a/components/screens/navigation/MainStack.tsx b/components/screens/navigation/MainStack.tsx new file mode 100644 index 00000000..3e1a14ee --- /dev/null +++ b/components/screens/navigation/MainStack.tsx @@ -0,0 +1,199 @@ +import {RouteProp} from '@react-navigation/native'; +import {createNativeStackNavigator, NativeStackScreenProps} from '@react-navigation/native-stack'; +import {AvalancheCenters} from 'components/avalancheCenterList'; +import {NavigationHeader} from 'components/content/NavigationHeader'; +import {View} from 'components/core'; +import {FeatureFlagsDebuggerScreen} from 'components/FeatureFlagsDebugger'; +import {NWACObservationDetailView, ObservationDetailView} from 'components/observations/ObservationDetailView'; +import {ObservationForm} from 'components/observations/ObservationForm'; +import {ObservationsPortal} from 'components/observations/ObservationsPortal'; +import {ForecastScreen} from 'components/screens/ForecastScreen'; +import {AboutScreen} from 'components/screens/main/AboutScreen'; +import {AvalancheCenterSelectorScreen} from 'components/screens/main/AvalancheCenterSelectorScreen'; +import { + AvalancheComponentPreview, + ButtonStylePreview, + DeveloperMenuScreen, + ExpoConfigScreen, + OutcomeScreen, + TextStylePreview, + TimeMachine, + ToastPreview, +} from 'components/screens/main/DeveloperMenu'; +import {BottomTabs} from 'components/screens/navigation/BottomTabs'; +import {WeatherStationDetail} from 'components/weather_data/WeatherStationDetail'; +import {WeatherStationsDetail} from 'components/weather_data/WeatherStationsDetail'; +import {usePreferences} from 'Preferences'; +import React, {useCallback} from 'react'; +import {StyleSheet} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {BackButtonDisplayMode} from 'react-native-screens'; +import {MainStackParamList} from 'routes'; +import {AvalancheCenterID} from 'types/nationalAvalancheCenter'; +import {parseRequestedTimeString, RequestedTime} from 'utils/date'; + +const MainStack = createNativeStackNavigator(); +export const MainStackNavigator: React.FunctionComponent<{ + requestedTime: RequestedTime; + centerId: AvalancheCenterID; + staging: boolean; + setStaging: React.Dispatch>; +}> = ({requestedTime, centerId, staging, setStaging}) => { + const {setPreferences} = usePreferences(); + + const setAvalancheCenter = useCallback( + (avalancheCenterId: AvalancheCenterID) => { + setPreferences({center: avalancheCenterId}); + }, + [setPreferences], + ); + + const renderBottomTabs = useCallback((_: {route: RouteProp}) => , [requestedTime]); + + const avalancheCenterSelectorOptions = useCallback( + (_: {route: RouteProp}) => ({ + headerShown: true, + title: `Select Avalanche Center`, + headerBackButtonDisplayMode: 'minimal' as BackButtonDisplayMode, + }), + [], + ); + + return ( + }} initialRouteName="bottomTabs"> + {/* Renders the bottom tab screens */} + + {renderBottomTabs} + + + {/* We don't want to show the bottom tab on these views. As such they cannot be a part of the bottom tab navigator */} + + + + + + + + + + + + {/* Developer Menu Screens */} + + + + + + + + + + + + + ); +}; + +// MARK: Observation Screens + +const ObservationsPortalScreen = ({route}: NativeStackScreenProps) => { + const {requestedTime} = route.params; + const {preferences} = usePreferences(); + const center_id = preferences.center; + return ; +}; + +const ObservationSubmitScreen = () => { + const {preferences} = usePreferences(); + const center_id = preferences.center; + return ; +}; + +export const ObservationDetailScreen = ({route}: NativeStackScreenProps) => { + const {id} = route.params; + return ( + + + + ); +}; + +export const NWACObservationDetailScreen = ({route}: NativeStackScreenProps) => { + const {id} = route.params; + return ( + + + + ); +}; + +// MARK: Weather Station Screens + +export const WeatherStationsDetailScreen = ({route}: NativeStackScreenProps) => { + const {preferences} = usePreferences(); + const center_id = preferences.center; + return ( + + {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there, or top edge since StackHeader is sitting there */} + + + + + ); +}; + +export const WeatherStationDetailScreen = ({route}: NativeStackScreenProps) => { + const {preferences} = usePreferences(); + const center_id = preferences.center; + return ( + + {/* SafeAreaView shouldn't inset from bottom edge because TabNavigator is sitting there, or top edge since StackHeader is sitting there */} + + + + + ); +}; + +const styles = StyleSheet.create({ + fullScreen: { + flex: 1, + }, +}); diff --git a/components/weather_data/NWACWeatherStationList.tsx b/components/weather_data/NWACWeatherStationList.tsx index b54614ad..024e73a1 100644 --- a/components/weather_data/NWACWeatherStationList.tsx +++ b/components/weather_data/NWACWeatherStationList.tsx @@ -10,7 +10,7 @@ import {BodyBlack} from 'components/text'; import {useAllMapLayers} from 'hooks/useAllMapLayers'; import {useWeatherStationsMetadata} from 'hooks/useWeatherStationsMetadata'; import {logger} from 'logger'; -import {WeatherStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import {mapFeaturesForCenter, MapLayerFeature, WeatherStationCollection, WeatherStationProperties, WeatherStationSource} from 'types/nationalAvalancheCenter'; import {RequestedTimeString} from 'utils/date'; @@ -106,7 +106,7 @@ export const NWACStationsByZone = (mapLayerFeatures: MapLayerFeature[] | undefin }; export const NWACStationList: React.FunctionComponent<{token: string; requestedTime: RequestedTimeString}> = ({token, requestedTime}) => { - const navigation = useNavigation(); + const navigation = useNavigation(); const mapLayerResult = useAllMapLayers(); const mapLayer = mapLayerResult.data; const weatherStationsResult = useWeatherStationsMetadata('NWAC', token); diff --git a/components/weather_data/WeatherStationMap.tsx b/components/weather_data/WeatherStationMap.tsx index f68edbe7..8581157a 100644 --- a/components/weather_data/WeatherStationMap.tsx +++ b/components/weather_data/WeatherStationMap.tsx @@ -22,7 +22,7 @@ import {formatInTimeZone} from 'date-fns-tz'; import {FeatureCollection, Point} from 'geojson'; import {LoggerContext, LoggerProps} from 'loggerContext'; import {usePostHog} from 'posthog-react-native'; -import {WeatherStackNavigationProps} from 'routes'; +import {MainStackNavigationProps} from 'routes'; import {colorLookup} from 'theme'; import { AvalancheCenterID, @@ -95,7 +95,7 @@ export const WeatherStationMap: React.FunctionComponent<{ }, [postHog, center_id]); useFocusEffect(recordAnalytics); - const navigation = useNavigation(); + const navigation = useNavigation(); const [selectedStationId, setSelectedStationId] = useState(null); const onPressMarker = React.useCallback( (station: WeatherStation) => { @@ -316,7 +316,7 @@ export const WeatherStationCard: React.FunctionComponent<{ mode: 'map' | 'list'; }) => { const {width} = useWindowDimensions(); - const navigation = useNavigation(); + const navigation = useNavigation(); const latestObservationDateString = weatherStationCardDateString(station.properties.data['date_time']); const latestObservation: Record | undefined = station.properties.data; diff --git a/docs/coding-guide.md b/docs/coding-guide.md index ef2fa0c7..8b0f2fa9 100644 --- a/docs/coding-guide.md +++ b/docs/coding-guide.md @@ -611,7 +611,6 @@ TabNavigator Home -> HomeStack (MapScreen, ForecastScreen, weather/observation details) Observations -> ObservationsStack (portal, list, detail, submit) Weather Data -> WeatherStack (station list, detail) - Menu -> MenuStack (settings, center selector, previews, about) ``` Each tab has its own native stack navigator. The forecast detail view uses material top tabs for sub-sections (avalanche, weather, observations, blog). diff --git a/package.json b/package.json index 8d056b87..c85feba7 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@react-native/babel-preset": "*", "@react-native/normalize-color": "^2.0.0", "@react-navigation/bottom-tabs": "^7.10.1", + "@react-navigation/drawer": "^7.9.2", "@react-navigation/material-top-tabs": "^7.4.13", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.11.0", diff --git a/routes.ts b/routes.ts index a87431f6..1d8e90f1 100644 --- a/routes.ts +++ b/routes.ts @@ -1,34 +1,21 @@ import {BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import {DrawerNavigationProp} from '@react-navigation/drawer'; import {MaterialTopTabNavigationProp} from '@react-navigation/material-top-tabs'; -import {NavigatorScreenParams} from '@react-navigation/native'; import {NativeStackNavigationProp} from '@react-navigation/native-stack'; import {AvalancheCenterID, WeatherStationSource} from 'types/nationalAvalancheCenter'; import {RequestedTimeString} from 'utils/date'; export type TabNavigatorParamList = { - Home: NavigatorScreenParams & {requestedTime: RequestedTimeString}; - 'Weather Data': NavigatorScreenParams & {requestedTime: RequestedTimeString}; - Observations: NavigatorScreenParams & {requestedTime: RequestedTimeString}; - Menu: NavigatorScreenParams & {requestedTime: RequestedTimeString}; + Map: {center_id: AvalancheCenterID; requestedTime: RequestedTimeString}; + Weather: {center_id: AvalancheCenterID; requestedTime: RequestedTimeString}; + Observations: {center_id: AvalancheCenterID; requestedTime: RequestedTimeString}; }; -export type TabNavigationProps = BottomTabNavigationProp; -type WeatherStationsDetailPageProps = { - center_id: AvalancheCenterID; - zoneName: string; - name: string; - stations: Record; - requestedTime: RequestedTimeString; -}; +export type TabNavigationProps = BottomTabNavigationProp; -type WeatherStationDetailPageProps = { - center_id: AvalancheCenterID; - stationId: string; - source: WeatherStationSource; - requestedTime: RequestedTimeString; -}; +export type MainStackParamList = { + bottomTabs: TabNavigationProps | undefined; -export type HomeStackParamList = { avalancheCenter: { center_id: AvalancheCenterID; requestedTime: RequestedTimeString; @@ -49,11 +36,59 @@ export type HomeStackParamList = { nwacObservation: { id: string; }; - observationSubmit: { + observationSubmit: undefined; + observationsPortal: { center_id: AvalancheCenterID; + requestedTime: RequestedTimeString; + }; + + // These screens are navigated to from the drawer + avalancheCenterSelector: { + debugMode: boolean; }; + + about: undefined; + outcome: { + which: string; + }; + + // These screens are for the developer menu + developerMenu: { + staging: boolean; + setStaging: React.Dispatch>; + }; + + buttonStylePreview: undefined; + textStylePreview: undefined; + avalancheComponentPreview: undefined; + toastPreview: undefined; + timeMachine: undefined; + featureFlags: undefined; + expoConfig: undefined; +}; + +export type MainStackNavigationProps = NativeStackNavigationProp; + +export type DrawerParamList = { + MainStack: MainStackNavigationProps | undefined; +}; + +export type SideDrawerNavigationProps = DrawerNavigationProp; + +type WeatherStationsDetailPageProps = { + center_id: AvalancheCenterID; + zoneName: string; + name: string; + stations: Record; + requestedTime: RequestedTimeString; +}; + +type WeatherStationDetailPageProps = { + center_id: AvalancheCenterID; + stationId: string; + source: WeatherStationSource; + requestedTime: RequestedTimeString; }; -export type HomeStackNavigationProps = NativeStackNavigationProp; type ForecastPageProps = { center_id: AvalancheCenterID; @@ -68,82 +103,3 @@ export type ForecastTabNavigatorParamList = { blog: ForecastPageProps; }; export type ForecastTabNavigatorProps = MaterialTopTabNavigationProp; - -export type WeatherStackParamList = { - stationList: { - center_id: AvalancheCenterID; - requestedTime: RequestedTimeString; - }; - stationsDetail: WeatherStationsDetailPageProps; - stationDetail: WeatherStationDetailPageProps; -}; -export type WeatherStackNavigationProps = NativeStackNavigationProp; - -export type TelemetryStackParamList = { - telemetryStations: { - center_id: AvalancheCenterID; - requestedTime: RequestedTimeString; - }; - telemetryStation: { - center_id: AvalancheCenterID; - source: string; - station_id: number; - name: string; - requestedTime: RequestedTimeString; - }; -}; -export type TelemetryStackNavigationProps = NativeStackNavigationProp; - -export type ObservationsStackParamList = { - observationsPortal: { - center_id: AvalancheCenterID; - requestedTime: RequestedTimeString; - }; - observationSubmit: undefined; - observationsList: { - center_id: AvalancheCenterID; - requestedTime: RequestedTimeString; - }; - observation: { - id: string; - }; - nwacObservation: { - id: string; - }; -}; -export type ObservationsStackNavigationProps = NativeStackNavigationProp; - -export type MenuStackParamList = { - menu: undefined; - avalancheCenterSelector: { - debugMode: boolean; - }; - buttonStylePreview: undefined; - textStylePreview: undefined; - avalancheComponentPreview: undefined; - toastPreview: undefined; - timeMachine: undefined; - avalancheCenter: { - center_id: AvalancheCenterID; - requestedTime: RequestedTimeString; - }; - forecast: { - center_id: AvalancheCenterID; - forecast_zone_id: number; - requestedTime: RequestedTimeString; - }; - observation: { - id: string; - }; - nwacObservation: { - id: string; - }; - about: undefined; - outcome: { - which: string; - }; - - expoConfig: undefined; - featureFlags: undefined; -}; -export type MenuStackNavigationProps = NativeStackNavigationProp; diff --git a/yarn.lock b/yarn.lock index 1b5e7248..fc1668c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2423,6 +2423,16 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" +"@react-navigation/drawer@^7.9.2": + version "7.9.2" + resolved "https://registry.yarnpkg.com/@react-navigation/drawer/-/drawer-7.9.2.tgz#b964177890db18f0edfcee873788cd691ceced79" + integrity sha512-hVpRBcsOQlTm9UMwlXqoB2wRIMgjDxZ75ksiqCU5Bo5MUk8h/k4bzY9SO2/ViORjELOkzPZVRas+107H15/Wmw== + dependencies: + "@react-navigation/elements" "^2.9.8" + color "^4.2.3" + react-native-drawer-layout "^4.2.2" + use-latest-callback "^0.2.4" + "@react-navigation/elements@^2.9.5": version "2.9.5" resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.9.5.tgz#29f68c4975351724dcfe1d3bdc76c4d6dc65fc33" @@ -2432,6 +2442,15 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" +"@react-navigation/elements@^2.9.8": + version "2.9.8" + resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.9.8.tgz#88833bc7a73e1f022302ea99171cbbb8fe509a9b" + integrity sha512-3gpwUmVnDJYvK9nFmAA/YXw0hmT/C/lZx8RkRMK+ux9l1T+32EWnQFnn34Wa1BMDX8HN2r64yrlW93DIzKI7Uw== + dependencies: + color "^4.2.3" + use-latest-callback "^0.2.4" + use-sync-external-store "^1.5.0" + "@react-navigation/material-top-tabs@^7.4.13": version "7.4.13" resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.13.tgz#6a2ccb1bc7b044e11716d5ec9f74deff8c619e36" @@ -9636,6 +9655,14 @@ react-native-collapsible@^1.6.0: resolved "https://registry.yarnpkg.com/react-native-collapsible/-/react-native-collapsible-1.6.2.tgz#3b67fa402a6ba3c291022f5db8f345083862c3d8" integrity sha512-MCOBVJWqHNjnDaGkvxX997VONmJeebh6wyJxnHEgg0L1PrlcXU1e/bo6eK+CDVFuMrCafw8Qh4DOv/C4V/+Iew== +react-native-drawer-layout@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-4.2.2.tgz#57832c186158e1ce1df78eca5f024fc9fc53bb80" + integrity sha512-UG/PTTeyyr43KahbgoGyXri8LMO5USHY3/RUpeKBKwCc7xLVGnDLOVNSRrJw0dDc7YmPbmAyJ4oxp8nKboKKuw== + dependencies: + color "^4.2.3" + use-latest-callback "^0.2.4" + react-native-edge-to-edge@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz#2ba63b941704a7f713e298185c26cde4d9e4b973"