diff --git a/messages/en.json b/messages/en.json index 5976c4616..969332023 100644 --- a/messages/en.json +++ b/messages/en.json @@ -354,12 +354,18 @@ }, "screens.AudioPermission.description": { "description": "Screen description for audio permission screen", - "message": "To record audio while using the app and in the background CoMapeo needs to access your microphone." + "message": "To record audio while using the app and in the background CoMapeo needs to access your microphone. Please enable microphone permissions in your app settings." }, "screens.AudioPermission.title": { "description": "Screen title for audio permission screen", "message": "Recording Audio with CoMapeo" }, + "screens.AudioScreen.CreateRecording.RecordingActive.description": { + "message": "Less than {length} {length, plural, one {minute} other {minutes}} left" + }, + "screens.AudioScreen.CreateRecording.RecordingIdle.description": { + "message": "Record up to {length} {length, plural, one {minute} other {minutes}}" + }, "screens.CameraScreen.goToSettings": { "message": "Go to Settings" }, diff --git a/src/frontend/Navigation/Stack/AppScreens.tsx b/src/frontend/Navigation/Stack/AppScreens.tsx index 52c35a0c8..d2b89e90e 100644 --- a/src/frontend/Navigation/Stack/AppScreens.tsx +++ b/src/frontend/Navigation/Stack/AppScreens.tsx @@ -76,6 +76,10 @@ import {SettingsPrivacyPolicy} from '../../screens/Settings/DataAndPrivacy/Setti import {TrackEdit} from '../../screens/TrackEdit/index.tsx'; import {Config} from '../../screens/Settings/Config'; import {HowToLeaveProject} from '../../screens/HowToLeaveProject.tsx'; +import { + Audio, + navigationOptions as audioNavigationOptions, +} from '../../screens/Audio/index.tsx'; export const TAB_BAR_HEIGHT = 70; @@ -322,7 +326,11 @@ export const createDefaultScreenGroup = ({ component={HowToLeaveProject} options={{headerShown: false}} /> - + {process.env.EXPO_PUBLIC_FEATURE_TEST_DATA_UI && ( ; +}) { + const {top} = useSafeAreaInsets(); + const {height} = Dimensions.get('window'); + + const animatedStyles = useAnimatedStyle(() => ({ + height: + (height + top) * + (elapsedTimeValue.value * (1 / MAX_RECORDING_DURATION_MS)), + backgroundColor: `hsl(216, 100%, ${elapsedTimeValue.value * (1 / MAX_RECORDING_DURATION_MS) * 50}%)`, + })); + + return ; +} + +const styles = StyleSheet.create({ + fill: { + position: 'absolute', + zIndex: -1, + bottom: 0, + width: '100%', + }, +}); diff --git a/src/frontend/screens/Audio/ContentWithControls.tsx b/src/frontend/screens/Audio/ContentWithControls.tsx new file mode 100644 index 000000000..784745090 --- /dev/null +++ b/src/frontend/screens/Audio/ContentWithControls.tsx @@ -0,0 +1,78 @@ +import React, {ReactNode} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Bar} from 'react-native-progress'; +import {Duration} from 'luxon'; + +import {MEDIUM_GREY, WHITE} from '../../lib/styles'; +import {ScreenContentWithDock} from '../../sharedComponents/ScreenContentWithDock'; +import {Text} from '../../sharedComponents/Text'; + +export function ContentWithControls({ + controls, + message, + progress, + timeElapsed, +}: { + controls: ReactNode; + message?: string; + progress?: number; + timeElapsed: number; +}) { + return ( + + + + + {Duration.fromMillis(timeElapsed).toFormat('mm:ss')} + + + {typeof progress === 'number' ? ( + 0 ? progress : 0.00000001} + indeterminate={false} + width={null} + color={WHITE} + borderColor="transparent" + borderRadius={0} + borderWidth={0} + unfilledColor={MEDIUM_GREY} + /> + ) : ( + + )} + + {message} + + + ); +} + +const styles = StyleSheet.create({ + contentContainer: {flex: 1}, + dockContainer: {paddingVertical: 24}, + container: { + flex: 1, + justifyContent: 'flex-end', + }, + timerContainer: { + flex: 1, + justifyContent: 'center', + gap: 48, + }, + message: { + color: WHITE, + fontSize: 20, + textAlign: 'center', + }, + timerText: { + fontFamily: 'Rubik', + fontSize: 96, + fontWeight: 'bold', + color: WHITE, + textAlign: 'center', + }, +}); diff --git a/src/frontend/screens/Audio/Controls.tsx b/src/frontend/screens/Audio/Controls.tsx new file mode 100644 index 000000000..9a8989f0d --- /dev/null +++ b/src/frontend/screens/Audio/Controls.tsx @@ -0,0 +1,85 @@ +import React, {PropsWithChildren} from 'react'; +import {Pressable, PressableProps, StyleSheet, View} from 'react-native'; + +import {MAGENTA, BLACK, LIGHT_GREY, WHITE} from '../../lib/styles'; + +type BaseProps = PropsWithChildren; + +function ControlButtonPrimaryBase({children, ...pressableProps}: BaseProps) { + return ( + [ + styles.basePressable, + typeof pressableProps.style === 'function' + ? pressableProps.style({pressed}) + : pressableProps.style, + pressed && styles.pressablePressed, + ]}> + {children} + + ); +} + +export function Record(props: BaseProps) { + return ( + + + + ); +} + +export function Stop(props: BaseProps) { + return ( + + + + ); +} + +export function Row({children}: PropsWithChildren) { + return {children}; +} + +const PRIMARY_CONTROL_DIAMETER = 96; + +const styles = StyleSheet.create({ + basePressable: { + height: PRIMARY_CONTROL_DIAMETER, + width: PRIMARY_CONTROL_DIAMETER, + borderRadius: PRIMARY_CONTROL_DIAMETER, + borderWidth: 12, + borderColor: WHITE, + overflow: 'hidden', + backgroundColor: WHITE, + justifyContent: 'center', + }, + pressablePressed: { + backgroundColor: LIGHT_GREY, + borderColor: LIGHT_GREY, + }, + + record: { + height: PRIMARY_CONTROL_DIAMETER, + backgroundColor: MAGENTA, + }, + stop: { + height: PRIMARY_CONTROL_DIAMETER / 3, + width: PRIMARY_CONTROL_DIAMETER / 3, + backgroundColor: BLACK, + alignSelf: 'center', + }, + play: { + justifyContent: 'center', + alignItems: 'center', + }, + + controlsRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + sideControl: { + position: 'absolute', + }, +}); diff --git a/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx b/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx new file mode 100644 index 000000000..9d3d22351 --- /dev/null +++ b/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx @@ -0,0 +1,64 @@ +import React, {useEffect} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {useDerivedValue, withTiming} from 'react-native-reanimated'; + +import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes'; +import {AnimatedBackground} from '../AnimatedBackground'; +import {ContentWithControls} from '../ContentWithControls'; +import * as Controls from '../Controls'; +import {MAX_RECORDING_DURATION_MS} from '../constants'; +import {useAutoStopRecording} from './useAutoStopRecording'; + +const m = defineMessages({ + description: { + id: 'screens.AudioScreen.CreateRecording.RecordingActive.description', + defaultMessage: + 'Less than {length} {length, plural, one {minute} other {minutes}} left', + }, +}); + +export function RecordingActive({ + duration, + onPressStop, +}: { + duration: number; + onPressStop: () => void; +}) { + const navigation = useNavigationFromRoot(); + const {formatMessage: t} = useIntl(); + + const minutesRemaining = Math.ceil( + (MAX_RECORDING_DURATION_MS - duration) / 60_000, + ); + + const elapsedTimeValue = useDerivedValue(() => { + return withTiming(duration, {duration: 500}); + }, [duration]); + + useEffect(() => { + navigation.setOptions({headerLeft: () => null}); + }, [navigation]); + + useAutoStopRecording(minutesRemaining, onPressStop); + + return ( + <> + 0 + ? t(m.description, { + length: minutesRemaining, + }) + : undefined + } + controls={ + + + + } + /> + + + ); +} diff --git a/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx b/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx new file mode 100644 index 000000000..d41adadb6 --- /dev/null +++ b/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx @@ -0,0 +1,46 @@ +import React, {useEffect} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; + +import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes'; +import {CustomHeaderLeft} from '../../../sharedComponents/CustomHeaderLeft'; +import {ContentWithControls} from '../ContentWithControls'; +import * as Controls from '../Controls'; +import {MAX_RECORDING_DURATION_MS} from '../constants'; + +const m = defineMessages({ + description: { + id: 'screens.AudioScreen.CreateRecording.RecordingIdle.description', + defaultMessage: + 'Record up to {length} {length, plural, one {minute} other {minutes}}', + }, +}); + +export function RecordingIdle({onPressRecord}: {onPressRecord: () => void}) { + const navigation = useNavigationFromRoot(); + const {formatMessage: t} = useIntl(); + + useEffect(() => { + navigation.setOptions({ + headerLeft: props => ( + + ), + }); + }, [navigation]); + + return ( + + + + } + /> + ); +} diff --git a/src/frontend/screens/Audio/CreateRecording/index.tsx b/src/frontend/screens/Audio/CreateRecording/index.tsx new file mode 100644 index 000000000..b5d9167d7 --- /dev/null +++ b/src/frontend/screens/Audio/CreateRecording/index.tsx @@ -0,0 +1,34 @@ +import React, {useEffect} from 'react'; +import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes'; +import {RecordingActive} from './RecordingActive'; +import {RecordingIdle} from './RecordingIdle'; +import {useAudioRecording} from './useAudioRecording'; + +export function CreateRecording() { + const navigation = useNavigationFromRoot(); + const recordingState = useAudioRecording(); + + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + recordingState.reset().catch(error => { + console.error('Error resetting recording:', error); + }); + }); + + return unsubscribe; + }, [navigation, recordingState]); + + switch (recordingState.status) { + case 'idle': { + return ; + } + case 'active': { + return ( + + ); + } + } +} diff --git a/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts b/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts new file mode 100644 index 000000000..db12a0b92 --- /dev/null +++ b/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts @@ -0,0 +1,74 @@ +import {useState} from 'react'; +import {Audio} from 'expo-av'; + +type AudioRecordingIdle = { + status: 'idle'; + startRecording: () => Promise; + reset: () => Promise; +}; + +type AudioRecordingActive = { + status: 'active'; + /** + * Time elapsed in milliseconds + */ + duration: number; + stopRecording: () => Promise; + reset: () => Promise; +}; + +type AudioRecordingState = AudioRecordingIdle | AudioRecordingActive; + +export function useAudioRecording(): AudioRecordingState { + const [state, setState] = useState<{ + recording: Audio.Recording; + status: Audio.RecordingStatus; + } | null>(null); + + const reset = async () => { + if (state) { + if (state.status.isRecording) { + await state.recording.stopAndUnloadAsync(); + } + } + setState(null); + }; + + if (!state) { + return { + status: 'idle', + startRecording: async () => { + const {recording, status} = await Audio.Recording.createAsync( + Audio.RecordingOptionsPresets.HIGH_QUALITY, + status => { + setState(prev => { + if (!prev) return prev; + return { + ...prev, + status, + }; + }); + }, + 1000, + ); + + setState({recording, status}); + }, + reset, + }; + } + + return { + status: 'active', + duration: state.status.durationMillis, + stopRecording: async () => { + const status = await state.recording.stopAndUnloadAsync(); + + setState(prev => { + if (!prev) return prev; + return {...prev, status}; + }); + }, + reset, + }; +} diff --git a/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts b/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts new file mode 100644 index 000000000..cdcf55d40 --- /dev/null +++ b/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts @@ -0,0 +1,12 @@ +import {useEffect} from 'react'; + +export function useAutoStopRecording( + minutesRemaining: number, + onPressStop: () => void, +) { + useEffect(() => { + if (minutesRemaining === 0) { + onPressStop(); + } + }, [minutesRemaining, onPressStop]); +} diff --git a/src/frontend/screens/Audio/constants.ts b/src/frontend/screens/Audio/constants.ts new file mode 100644 index 000000000..6462d61b0 --- /dev/null +++ b/src/frontend/screens/Audio/constants.ts @@ -0,0 +1 @@ +export const MAX_RECORDING_DURATION_MS = 5 * 60_000; diff --git a/src/frontend/screens/Audio/index.tsx b/src/frontend/screens/Audio/index.tsx new file mode 100644 index 000000000..8549f37ed --- /dev/null +++ b/src/frontend/screens/Audio/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +import {DARK_GREY, WHITE} from '../../lib/styles'; +import {CustomHeaderLeft} from '../../sharedComponents/CustomHeaderLeft'; +import {StatusBar} from 'expo-status-bar'; +import {CreateRecording} from './CreateRecording'; + +export function Audio() { + return ( + <> + + + + ); +} + +export const navigationOptions: NativeStackNavigationOptions = { + contentStyle: {backgroundColor: DARK_GREY}, + headerTintColor: WHITE, + headerShadowVisible: false, + headerTitle: () => null, + headerStyle: {backgroundColor: 'transparent'}, + headerTransparent: true, + headerLeft: props => ( + + ), +}; diff --git a/src/frontend/sharedComponents/ActionRow.tsx b/src/frontend/sharedComponents/ActionRow.tsx index 41e718ff6..8c7d82ced 100644 --- a/src/frontend/sharedComponents/ActionRow.tsx +++ b/src/frontend/sharedComponents/ActionRow.tsx @@ -1,10 +1,14 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {ActionTab} from './ActionTab'; import PhotoIcon from '../images/observationEdit/Photo.svg'; +import AudioIcon from '../images/observationEdit/Audio.svg'; import DetailsIcon from '../images/observationEdit/Details.svg'; import {useNavigationFromRoot} from '../hooks/useNavigationWithTypes'; import {Preset} from '@comapeo/schema'; +import {PermissionAudio} from './PermissionAudio'; +import {Audio} from 'expo-av'; +import {useBottomSheetModal} from '../sharedComponents/BottomSheetModal'; const m = defineMessages({ audioButton: { @@ -23,23 +27,37 @@ const m = defineMessages({ description: 'Button label to add details', }, }); - interface ActionButtonsProps { fieldRefs?: Preset['fieldRefs']; } - export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => { const {formatMessage: t} = useIntl(); const navigation = useNavigationFromRoot(); + const { + openSheet: openAudioPermissionSheet, + sheetRef: audioPermissionSheetRef, + closeSheet: closeAudioPermissionSheet, + isOpen: isAudioPermissionSheetOpen, + } = useBottomSheetModal({ + openOnMount: false, + }); const handleCameraPress = () => { navigation.navigate('AddPhoto'); }; - const handleDetailsPress = () => { navigation.navigate('ObservationFields', {question: 1}); }; + const handleAudioPress = useCallback(async () => { + const {status} = await Audio.getPermissionsAsync(); + if (status === 'granted') { + navigation.navigate('Audio'); + } else { + openAudioPermissionSheet(); + } + }, [navigation, openAudioPermissionSheet]); + const bottomSheetItems = [ { icon: , @@ -49,8 +67,15 @@ export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => { }, ]; + if (process.env.EXPO_PUBLIC_FEATURE_AUDIO) { + bottomSheetItems.unshift({ + icon: , + label: t(m.audioButton), + onPress: handleAudioPress, + testID: 'OBS.add-audio-btn', + }); + } if (fieldRefs?.length) { - // Only show the option to add details if preset fields are defined. bottomSheetItems.push({ icon: , label: t(m.detailsButton), @@ -59,5 +84,14 @@ export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => { }); } - return ; + return ( + <> + + + + ); }; diff --git a/src/frontend/sharedComponents/PermissionAudio.tsx b/src/frontend/sharedComponents/PermissionAudio.tsx index b944bd61c..500c1539a 100644 --- a/src/frontend/sharedComponents/PermissionAudio.tsx +++ b/src/frontend/sharedComponents/PermissionAudio.tsx @@ -1,72 +1,100 @@ -import React, {FC, useCallback, useEffect} from 'react'; +import React, {FC, useState, useEffect} from 'react'; import {Linking} from 'react-native'; import {defineMessages, useIntl} from 'react-intl'; -import {BottomSheetModalMethods} from '@gorhom/bottom-sheet/lib/typescript/types'; import AudioPermission from '../images/observationEdit/AudioPermission.svg'; import {BottomSheetModalContent, BottomSheetModal} from './BottomSheetModal'; import {Audio} from 'expo-av'; -import {PermissionStatus} from 'expo-av/build/Audio'; - -const handleRequestPermissions = (): void => { - Audio.requestPermissionsAsync().catch(() => {}); -}; - -const handleOpenSettings = () => { - Linking.openSettings(); -}; +import {BottomSheetModalMethods} from '@gorhom/bottom-sheet/lib/typescript/types'; +import {PermissionResponse} from 'expo-modules-core'; +import {useNavigationFromRoot} from '../hooks/useNavigationWithTypes'; -interface PermissionAudio { - sheetRef: React.RefObject; +const m = defineMessages({ + title: { + id: 'screens.AudioPermission.title', + defaultMessage: 'Recording Audio with CoMapeo', + description: 'Screen title for audio permission screen', + }, + description: { + id: 'screens.AudioPermission.description', + defaultMessage: + 'To record audio while using the app and in the background CoMapeo needs to access your microphone. Please enable microphone permissions in your app settings.', + description: 'Screen description for audio permission screen', + }, + notNowButtonText: { + id: 'screens.AudioPermission.Button.notNow', + defaultMessage: 'Not Now', + description: 'Screen button text for not allowed audio permission', + }, + allowButtonText: { + id: 'screens.AudioPermission.Button.allow', + defaultMessage: 'Allow', + description: 'Screen button text for allow the audio permission', + }, + goToSettingsButtonText: { + id: 'screens.AudioPermission.Button.goToSettings', + defaultMessage: 'Go to Settings', + description: + 'Screen button text for navigate user to settings when audio permission was denied', + }, +}); +interface PermissionAudioProps { closeSheet: () => void; isOpen: boolean; + sheetRef: React.RefObject; } -export const PermissionAudio: FC = props => { - const {sheetRef, closeSheet, isOpen} = props; +export const PermissionAudio: FC = ({ + closeSheet, + isOpen, + sheetRef, +}) => { const {formatMessage: t} = useIntl(); - const [permissionResponse] = Audio.usePermissions({request: false}); + const navigation = useNavigationFromRoot(); + const [permissionResponse, setPermissionResponse] = + useState(null); - const handlePermissionGranted = useCallback(() => { + const handleOpenSettings = () => { + Linking.openSettings(); closeSheet(); - }, [closeSheet]); - - const isPermissionGranted = Boolean(permissionResponse?.granted); + }; - useEffect(() => { - if (isPermissionGranted) handlePermissionGranted(); - }, [isPermissionGranted, handlePermissionGranted]); + const handleRequestPermission = async () => { + const response = await Audio.requestPermissionsAsync(); + setPermissionResponse(response); + if (response.status === 'granted') { + closeSheet(); + navigation.navigate('Audio'); + } else if (response.status === 'denied' && response.canAskAgain) { + closeSheet(); + } else if (response.status === 'denied' && !response.canAskAgain) { + handleOpenSettings(); + } + }; let onPressActionButton: () => void; let actionButtonText: string; - switch (permissionResponse?.status) { - case undefined: - case PermissionStatus.UNDETERMINED: - onPressActionButton = handleOpenSettings; - actionButtonText = t(m.allowButtonText); - break; - case PermissionStatus.DENIED: - if (permissionResponse.canAskAgain) { - onPressActionButton = handleOpenSettings; - actionButtonText = t(m.allowButtonText); - } else { - onPressActionButton = handleRequestPermissions; - actionButtonText = t(m.goToSettingsButtonText); - } - break; - case PermissionStatus.GRANTED: - onPressActionButton = handlePermissionGranted; - actionButtonText = t(m.allowButtonText); - break; - default: - throw new Error('Unexpected permission response'); + + if (!permissionResponse) { + onPressActionButton = async () => { + await handleRequestPermission(); + }; + actionButtonText = t(m.allowButtonText); + } else if (permissionResponse.status === 'denied') { + onPressActionButton = handleOpenSettings; + actionButtonText = t(m.goToSettingsButtonText); + } else { + onPressActionButton = async () => { + await handleRequestPermission(); + }; + actionButtonText = t(m.allowButtonText); } return ( + fullScreen> } title={t(m.title)} @@ -87,33 +115,3 @@ export const PermissionAudio: FC = props => { ); }; - -const m = defineMessages({ - title: { - id: 'screens.AudioPermission.title', - defaultMessage: 'Recording Audio with CoMapeo', - description: 'Screen title for audio permission screen', - }, - description: { - id: 'screens.AudioPermission.description', - defaultMessage: - 'To record audio while using the app and in the background CoMapeo needs to access your microphone.', - description: 'Screen description for audio permission screen', - }, - notNowButtonText: { - id: 'screens.AudioPermission.Button.notNow', - defaultMessage: 'Not Now', - description: 'Screen button text for not allowed audio permission', - }, - allowButtonText: { - id: 'screens.AudioPermission.Button.allow', - defaultMessage: 'Allow', - description: 'Screen button text for allow the audio permission', - }, - goToSettingsButtonText: { - id: 'screens.AudioPermission.Button.goToSettings', - defaultMessage: 'Go to Settings', - description: - 'Screen button text for navigate user to settings when audio permission was denied', - }, -}); diff --git a/src/frontend/sharedTypes/navigation.ts b/src/frontend/sharedTypes/navigation.ts index 82136bb73..7742e421c 100644 --- a/src/frontend/sharedTypes/navigation.ts +++ b/src/frontend/sharedTypes/navigation.ts @@ -102,6 +102,7 @@ export type RootStackParamsList = { DataAndPrivacy: undefined; SettingsPrivacyPolicy: undefined; HowToLeaveProject: undefined; + Audio: undefined; }; export type OnboardingParamsList = {