Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 77 additions & 15 deletions apps/native-component-list/src/screens/Audio/Recorder.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import Ionicons from '@expo/vector-icons/build/Ionicons';
import {
useAudioRecorder,
useAudioRecorderState,
AudioModule,
RecordingStatus,
RecordingOptions,
RecordingPresets,
} from 'expo-audio';
import { useAudioRecorder, useAudioRecorderState, AudioModule, RecordingPresets } from 'expo-audio';
import type { RecordingDirectory, RecordingOptions, RecordingStatus } from 'expo-audio';
import React, { useEffect } from 'react';
import {
Alert,
Platform,
ScrollView,
StyleProp,
StyleSheet,
Expand All @@ -30,6 +25,8 @@ type RecorderProps = {
style?: StyleProp<ViewStyle>;
};

const supportsRecordingDirectory = Platform.OS === 'android' || Platform.OS === 'ios';

export default function Recorder({ onDone, style }: RecorderProps) {
const [state, setState] = React.useState<RecordingStatus>({
id: 'initial',
Expand All @@ -41,8 +38,20 @@ export default function Recorder({ onDone, style }: RecorderProps) {
const [recorderOptions, setRecorderOptions] = React.useState<RecordingOptions>(
RecordingPresets.HIGH_QUALITY
);
const [recordingDirectory, setRecordingDirectory] = React.useState<RecordingDirectory>('cache');
const [lastUri, setLastUri] = React.useState<string | null>(null);
const [useAtTime, setUseAtTime] = React.useState(false);
const [useForDuration, setUseForDuration] = React.useState(false);
const currentRecorderOptions = React.useMemo<RecordingOptions>(
() =>
supportsRecordingDirectory
? {
...recorderOptions,
directory: recordingDirectory,
}
: recorderOptions,
[recorderOptions, recordingDirectory]
);

useEffect(() => {
(async () => {
Expand All @@ -53,7 +62,7 @@ export default function Recorder({ onDone, style }: RecorderProps) {
})();
}, []);

const audioRecorder = useAudioRecorder(recorderOptions, (status) => {
const audioRecorder = useAudioRecorder(currentRecorderOptions, (status) => {
if (status.mediaServicesDidReset) {
console.warn('[Recorder] Media services were reset');
Alert.alert(
Expand All @@ -70,8 +79,9 @@ export default function Recorder({ onDone, style }: RecorderProps) {
setState(status);

// Handle automatic recording completion (from forDuration or atTime+forDuration)
if (status.isFinished && !status.hasError && status.url && onDone) {
onDone(status.url);
if (status.isFinished && !status.hasError && status.url) {
setLastUri(status.url);
onDone?.(status.url);
}
});

Expand Down Expand Up @@ -104,6 +114,30 @@ export default function Recorder({ onDone, style }: RecorderProps) {
);
};

const renderDirectoryOptions = () => {
if (!supportsRecordingDirectory) {
return null;
}

return (
<View style={styles.optionRow}>
<BodyText style={styles.optionText}>Directory</BodyText>
<View style={styles.directoryButtons}>
<Button
onPress={() => setRecordingDirectory('cache')}
title={`${recordingDirectory === 'cache' ? '✓ ' : ''}Cache`}
buttonStyle={styles.directoryButton}
/>
<Button
onPress={() => setRecordingDirectory('document')}
title={`${recordingDirectory === 'document' ? '✓ ' : ''}Document`}
buttonStyle={styles.directoryButton}
/>
</View>
</View>
);
};

const togglePause = () => {
try {
if (audioRecorder.isRecording) {
Expand All @@ -117,9 +151,10 @@ export default function Recorder({ onDone, style }: RecorderProps) {
};

const stop = async () => {
if (onDone) {
await audioRecorder.stop();
onDone(audioRecorder.uri!);
await audioRecorder.stop();
if (audioRecorder.uri) {
setLastUri(audioRecorder.uri);
onDone?.(audioRecorder.uri);
}
setState((state) => ({ ...state, options: undefined, durationMillis: 0 }));
};
Expand Down Expand Up @@ -199,6 +234,7 @@ export default function Recorder({ onDone, style }: RecorderProps) {

{/* Recording Options */}
<View style={styles.optionsContainer}>
{renderDirectoryOptions()}
<View style={styles.optionRow}>
<BodyText style={styles.optionText}>Record at Time (3s delay - iOS only)</BodyText>
<Switch value={useAtTime} onValueChange={setUseAtTime} />
Expand All @@ -221,13 +257,21 @@ export default function Recorder({ onDone, style }: RecorderProps) {
<Button
onPress={async () => {
onDone?.('');
await audioRecorder.prepareToRecordAsync(recorderOptions);
await audioRecorder.prepareToRecordAsync(currentRecorderOptions);
}}
disabled={recorderState.canRecord}
title="Prepare Recording"
style={[!recorderState.canRecord && { backgroundColor: 'gray' }]}
/>
</View>
{!!lastUri && (
<View style={styles.uriContainer}>
<BodyText style={styles.uriLabel}>URI</BodyText>
<BodyText selectable style={styles.uriText}>
{lastUri}
</BodyText>
</View>
)}
<View style={styles.centerer}>
{renderRecorderButtons()}
<BodyText style={{ fontWeight: 'bold', marginVertical: 10 }}>
Expand Down Expand Up @@ -277,6 +321,13 @@ const styles = StyleSheet.create({
optionText: {
fontSize: 16,
},
directoryButtons: {
flexDirection: 'row',
gap: 8,
},
directoryButton: {
minWidth: 88,
},
optionsStatus: {
fontSize: 14,
color: Colors.tintColor,
Expand All @@ -289,6 +340,17 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginVertical: 5,
},
uriContainer: {
marginHorizontal: 20,
marginVertical: 8,
},
uriLabel: {
fontWeight: 'bold',
marginBottom: 4,
},
uriText: {
fontSize: 12,
},
icon: {
padding: 8,
fontSize: 24,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
BasicTextField,
BasicTextFieldRef,
Button,
Card,
Column,
Host,
LazyColumn,
Row,
Switch,
Text as ComposeText,
useNativeState,
} from '@expo/ui/jetpack-compose';
import { fillMaxWidth, padding, weight } from '@expo/ui/jetpack-compose/modifiers';
import * as React from 'react';

export default function BasicTextFieldScreen() {
const value = useNativeState('');
const [text, setText] = React.useState('');
const [focused, setFocused] = React.useState(false);
const [lastAction, setLastAction] = React.useState('none');
const ref = React.useRef<BasicTextFieldRef>(null);

const [enabled, setEnabled] = React.useState(true);
const [readOnly, setReadOnly] = React.useState(false);
const [singleLine, setSingleLine] = React.useState(true);
const [secure, setSecure] = React.useState(false);

const p = padding(16, 12, 16, 12);
const cardModifiers = [fillMaxWidth()];

return (
<Host style={{ flex: 1 }}>
<LazyColumn
verticalArrangement={{ spacedBy: 8 }}
modifiers={[padding(12, 8, 12, 8), fillMaxWidth()]}>
<Card modifiers={cardModifiers}>
<Column modifiers={[p]} verticalArrangement={{ spacedBy: 8 }}>
<ComposeText style={{ typography: 'labelLarge' }}>Undecorated</ComposeText>
<BasicTextField
ref={ref}
value={value}
enabled={enabled}
readOnly={readOnly}
singleLine={singleLine}
maxLines={singleLine ? undefined : 5}
cursorColor="#7c3aed"
visualTransformation={secure ? 'password' : 'none'}
textStyle={{ fontSize: 18, color: '#111827' }}
keyboardOptions={{ keyboardType: 'text', imeAction: 'done' }}
keyboardActions={{ onDone: (v) => setLastAction(`done: ${v}`) }}
onValueChange={setText}
onFocusChanged={setFocused}
modifiers={[fillMaxWidth()]}
/>
<ComposeText style={{ typography: 'bodySmall' }}>
Value: {JSON.stringify(text)} | Focused: {String(focused)} | Action: {lastAction}
</ComposeText>
<Row horizontalArrangement={{ spacedBy: 8 }}>
<Button onClick={() => ref.current?.setText('Reset!')}>
<ComposeText>setText</ComposeText>
</Button>
<Button onClick={() => ref.current?.clear()}>
<ComposeText>clear</ComposeText>
</Button>
<Button onClick={() => ref.current?.focus()}>
<ComposeText>focus</ComposeText>
</Button>
<Button onClick={() => ref.current?.blur()}>
<ComposeText>blur</ComposeText>
</Button>
</Row>
</Column>
</Card>

<Card modifiers={cardModifiers}>
<Column modifiers={[p]} verticalArrangement={{ spacedBy: 8 }}>
<ComposeText style={{ typography: 'labelLarge' }}>decorationBox</ComposeText>
<BasicTextField value={value} onValueChange={setText} modifiers={[fillMaxWidth()]}>
<BasicTextField.DecorationBox>
{text.length === 0 ? <ComposeText color="#9ca3af">Type here…</ComposeText> : null}
<BasicTextField.InnerTextField />
</BasicTextField.DecorationBox>
</BasicTextField>
<ComposeText style={{ typography: 'bodySmall' }}>
`decorationBox` wraps `InnerTextField`. Here the placeholder sits behind it and hides
once there's text.
</ComposeText>
</Column>
</Card>

{/* Props */}
<Card modifiers={cardModifiers}>
<Column modifiers={[p]} verticalArrangement={{ spacedBy: 2 }}>
<ComposeText style={{ typography: 'labelLarge' }}>Props</ComposeText>
<SwitchRow label="Enabled" value={enabled} onCheckedChange={setEnabled} />
<SwitchRow label="Read Only" value={readOnly} onCheckedChange={setReadOnly} />
<SwitchRow label="Single Line" value={singleLine} onCheckedChange={setSingleLine} />
<SwitchRow label="Secure (password)" value={secure} onCheckedChange={setSecure} />
</Column>
</Card>
</LazyColumn>
</Host>
);
}

function SwitchRow({
label,
value,
onCheckedChange,
}: {
label: string;
value: boolean;
onCheckedChange: (v: boolean) => void;
}) {
return (
<Row verticalAlignment="center" modifiers={[fillMaxWidth()]}>
<ComposeText modifiers={[weight(1)]}>{label}</ComposeText>
<Switch value={value} onCheckedChange={onCheckedChange} />
</Row>
);
}

BasicTextFieldScreen.navigationOptions = {
title: 'BasicTextField',
};
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ export default function TextFieldScreen() {
'worklet';
console.log('Value changed to:', newValue);
};
return () => {
fieldValue.onChange = null;
};
}, []);

const sharedProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,14 @@ export const UIScreens = [
return optionalRequire(() => require('./TextFieldScreen'));
},
},
{
name: 'BasicTextField component',
route: 'ui/basicTextField',
options: {},
getComponent() {
return optionalRequire(() => require('./BasicTextFieldScreen'));
},
},
{
name: 'Progress component',
route: 'ui/progress',
Expand Down
Loading
Loading