← Tasks
[UI-02] VoiceControls — PTT (onPressIn/onPressOut), mute, end, always-on toggle
completedcode_genP0
Description
## UI-02: VoiceControls — PTT (onPressIn/onPressOut), mute, end, always-on toggle
Section: UI Components | Milestone: M1 | Owner: AG Lu
### Failing Tests
- **controls_file** (file_exists): File `c8-audio/components/VoiceControls.tsx` must exist
- **ptt_press** (grep): Pattern `onPressIn|onPressOut|Pressable` in `c8-audio/components/VoiceControls.tsx` (need 1+ matches)
- **mic_toggle** (grep): Pattern `setMicrophoneEnabled|mute` in `c8-audio/components/VoiceControls.tsx` (need 1+ matches)
### Acceptance Criteria
- File `c8-audio/components/VoiceControls.tsx` must exist
- File matching `c8-audio/components/VoiceControls.tsx` must contain pattern `onPressIn|onPressOut|Pressable`
- File matching `c8-audio/components/VoiceControls.tsx` must contain pattern `setMicrophoneEnabled|mute`
### Codebase Conventions
- Trilingual comments: // EN: ... // VI: ... // ES: ...
- SPEC section refs in comments
- Zod at module level with .safeParse()
- (supabase as any) with eslint-disable
- NextResponse.json() responses
- Error format: { error: 'message' }
### Dependencies
- SC-02: FAIL
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
22d ago
Completed
19d ago
Quality
Overall
74%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"cost": {
"input_tokens": 40,
"estimated_usd": 0.056265,
"output_tokens": 3743
},
"model": "claude-sonnet-4-20250514",
"status": "success",
"transport": "anthropic_api",
"latency_ms": 40532,
"response_text": "I'll create a VoiceControls component with PTT (Push-to-Talk), mute, end call, and always-on toggle functionality.\n\n```jsx\nimport React, { useState, useCallback } from 'react';\nimport { \n View, \n TouchableOpacity, \n Text, \n StyleSheet, \n Vibration,\n Animated \n} from 'react-native';\nimport Icon from 'react-native-vector-icons/MaterialIcons';\n\nconst VoiceControls = ({\n onPTTStart,\n onPTTEnd,\n onMuteToggle,\n onEndCall,\n onAlwaysOnToggle,\n isMuted = false,\n isAlwaysOn = false,\n isPTTActive = false,\n disabled = false,\n style\n}) => {\n const [pttScale] = useState(new Animated.Value(1));\n\n const handlePTTStart = useCallback(() => {\n if (disabled || isAlwaysOn) return;\n \n Vibration.vibrate(50);\n onPTTStart?.();\n \n Animated.spring(pttScale, {\n toValue: 1.2,\n useNativeDriver: true,\n }).start();\n }, [disabled, isAlwaysOn, onPTTStart, pttScale]);\n\n const handlePTTEnd = useCallback(() => {\n if (disabled || isAlwaysOn) return;\n \n Vibration.vibrate(30);\n onPTTEnd?.();\n \n Animated.spring(pttScale, {\n toValue: 1,\n useNativeDriver: true,\n }).start();\n }, [disabled, isAlwaysOn, onPTTEnd, pttScale]);\n\n const handleMuteToggle = useCallback(() => {\n if (disabled) return;\n Vibration.vibrate(40);\n onMuteToggle?.();\n }, [disabled, onMuteToggle]);\n\n const handleEndCall = useCallback(() => {\n if (disabled) return;\n Vibration.vibrate([50, 50, 50]);\n onEndCall?.();\n }, [disabled, onEndCall]);\n\n const handleAlwaysOnToggle = useCallback(() => {\n if (disabled) return;\n Vibration.vibrate(60);\n onAlwaysOnToggle?.();\n }, [disabled, onAlwaysOnToggle]);\n\n return (\n <View style={[styles.container, style]}>\n {/* Secondary Controls Row */}\n <View style={styles.secondaryRow}>\n {/* Mute Toggle */}\n <TouchableOpacity\n style={[\n styles.secondaryButton,\n isMuted && styles.mutedButton,\n disabled && styles.disabledButton\n ]}\n onPress={handleMuteToggle}\n disabled={disabled}\n activeOpacity={0.7}\n >\n <Icon\n name={isMuted ? 'mic-off' : 'mic'}\n size={24}\n color={isMuted ? '#fff' : (disabled ? '#ccc' : '#333')}\n />\n <Text style={[\n styles.buttonLabel,\n isMuted && styles.activeLabel,\n disabled && styles.disabledLabel\n ]}>\n {isMuted ? 'Unmute' : 'Mute'}\n </Text>\n </TouchableOpacity>\n\n {/* Always-On Toggle */}\n <TouchableOpacity\n style={[\n styles.secondaryButton,\n isAlwaysOn && styles.alwaysOnButton,\n disabled && styles.disabledButton\n ]}\n onPress={handleAlwaysOnToggle}\n disabled={disabled}\n activeOpacity={0.7}\n >\n <Icon\n name={isAlwaysOn ? 'radio-button-checked' : 'radio-button-unchecked'}\n size={24}\n color={isAlwaysOn ? '#fff' : (disabled ? '#ccc' : '#333')}\n />\n <Text style={[\n styles.buttonLabel,\n isAlwaysOn && styles.activeLabel,\n disabled && styles.disabledLabel\n ]}>\n Always On\n </Text>\n </TouchableOpacity>\n </View>\n\n {/* Primary Controls Row */}\n <View style={styles.primaryRow}>\n {/* End Call Button */}\n <TouchableOpacity\n style={[styles.endButton, disabled && styles.disabledButton]}\n onPress={handleEndCall}\n disabled={disabled}\n activeOpacity={0.8}\n >\n <Icon name=\"call-end\" size={28} color=\"#fff\" />\n </TouchableOpacity>\n\n {/* PTT Button */}\n <Animated.View style={{ transform: [{ scale: pttScale }] }}>\n <TouchableOpacity\n style={[\n styles.pttButton,\n (isPTTActive || isAlwaysOn) && styles.pttActive,\n disabled && styles.disabledButton,\n isAlwaysOn && styles.alwaysOnActive\n ]}\n onPressIn={handlePTTStart}\n onPressOut={handlePTTEnd}\n disabled={disabled}\n activeOpacity={1}\n >\n <Icon\n name=\"mic\"\n size={32}\n color={\n disabled ? '#ccc' : \n (isPTTActive || isAlwaysOn) ? '#fff' : '#007AFF'\n }\n />\n <Text style={[\n styles.pttLabel,\n (isPTTActive || isAlwaysOn) && styles.pttActiveLabel,\n disabled && styles.disabledLabel\n ]}>\n {isAlwaysOn ? 'LIVE' : isPTTActive ? 'TALKING' : 'HOLD TO TALK'}\n </Text>\n </TouchableOpacity>\n </Animated.View>\n\n {/* Spacer for alignment */}\n <View style={styles.spacer} />\n </View>\n\n {/* Status Indicator */}\n {(isPTTActive || isAlwaysOn) && !disabled && (\n <View style={styles.statusContainer}>\n <View style={[\n styles.statusIndicator,\n isAlwaysOn ? styles.alwaysOnIndicator : styles.pttIndicator\n ]} />\n <Text style={styles.statusText}>\n {isAlwaysOn ? 'Voice Always On' : 'Push to Talk Active'}\n </Text>\n </View>\n )}\n </View>\n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n backgroundColor: '#f8f9fa',\n paddingVertical: 20,\n paddingHorizontal: 16,\n borderRadius: 16,\n alignItems: 'center',\n },\n secondaryRow: {\n flexDirection: 'row',\n justifyContent: 'space-around',\n width: '100%',\n marginBottom: 20,\n },\n primaryRow: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n width: '100%',\n paddingHorizontal: 20,\n },\n secondaryButton: {\n alignItems: 'center',\n justifyContent: 'center',\n paddingVertical: 12,\n paddingHorizontal: 16,\n borderRadius: 12,\n backgroundColor: '#fff',\n minWidth: 80,\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.1,\n shadowRadius: 4,\n elevation: 2,\n },\n mutedButton: {\n backgroundColor: '#ff6b6b',\n },\n alwaysOnButton: {\n backgroundColor: '#28a745',\n },\n buttonLabel: {\n fontSize: 12,\n fontWeight: '600',\n marginTop: 4,\n color: '#333',\n },\n activeLabel: {\n color: '#fff',\n },\n disabledLabel: {\n color: '#ccc',\n },\n pttButton: {\n width: 100,\n height: 100,\n borderRadius: 50,\n backgroundColor: '#fff',\n alignItems: 'center',\n justifyContent: 'center',\n borderWidth: 3,\n borderColor: '#007AFF',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 4 },\n shadowOpacity: 0.2,\n shadowRadius: 8,\n elevation: 4,\n },\n pttActive: {\n backgroundColor: '#007AFF',\n borderColor: '#0056CC',\n },\n alwaysOnActive: {\n backgroundColor: '#28a745',\n borderColor: '#1e7e34',\n },\n pttLabel: {\n fontSize: 10,\n fontWeight: 'bold',\n marginTop: 4,\n color: '#007AFF',\n textAlign: 'center',\n },\n pttActiveLabel: {\n color: '#fff',\n },\n endButton: {\n width: 56,\n height: 56,\n borderRadius: 28,\n backgroundColor: '#dc3545',\n alignItems: 'center',\n justifyContent: 'center',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.2,\n shadowRadius: 4,\n elevation: 3,\n },\n spacer: {\n width: 56,\n },\n disabledButton: {\n opacity: 0.5,\n },\n statusContainer: {\n flexDirection: 'row',\n alignItems: 'center',\n marginTop: 16,\n paddingHorizontal: 12,\n paddingVertical: 6,\n backgroundColor: 'rgba(0, 0, 0, 0.1)',\n borderRadius: 20,\n },\n statusIndicator: {\n width: 8,\n height: 8,\n borderRadius: 4,\n marginRight: 8,\n },\n pttIndicator: {\n backgroundColor: '#007AFF',\n },\n alwaysOnIndicator: {\n backgroundColor: '#28a745',\n },\n statusText: {\n fontSize: 12,\n color: '#666',\n fontWeight: '500',\n },\n});\n\nexport default VoiceControls;\n```\n\nAnd here's an example of how to use it:\n\n```jsx\nimport React, { useState } from 'react';\nimport { View, StyleSheet, Alert } from 'react-native';\nimport VoiceControls from './VoiceControls';\n\nconst VoiceCallScreen = () => {\n const [isMuted, setIsMuted] = useState(false);\n const [isAlwaysOn, setIsAlwaysOn] = useState(false);\n const [isPTTActive, setIsPTTActive] = useState(false);\n const [isCallActive, setIsCallActive] = useState(true);\n\n const handlePTTStart = () => {\n console.log('PTT Started');\n setIsPTTActive(true);\n // Start recording/transmitting\n };\n\n const handlePTTEnd = () => {\n console.log('PTT Ended');\n setIsPTTActive(false);\n // Stop recording/transmitting\n };\n\n const handleMuteToggle = () => {\n const newMutedState = !isMuted;\n setIsMuted(newMutedState);\n console.log('Mute toggled:', newMutedState);\n // Toggle microphone mute\n };\n\n const handleEndCall = () => {\n Alert.alert(\n 'End Call',\n 'Are you sure you want to end the call?',\n [\n { text: 'Cancel', style: 'cancel' },\n { \n text: 'End Call', \n style: 'destructive',\n onPress: () => {\n setIsCallActive(false);\n console.log('Call ended');\n // End call logic\n }\n }\n ]\n );\n };\n\n const handleAlwaysOnToggle = () => {\n const newAlwaysOnState = !isAlwaysOn;\n setIsAlwaysOn(newAlwaysOnState);\n console.log('Always-on toggled:', newAlwaysOnState);\n \n if (newAlwaysOnState) {\n // Start continuous transmission\n setIsPTTActive(true);\n } else {\n // Stop continuous transmission\n setIsPTTActive(false);\n }\n };\n\n return (\n <View style={styles.container}>\n <VoiceControls\n onPTTStart={handlePTTStart}\n onPTTEnd={handlePTTEnd}\n onMuteToggle={handleMuteToggle}\n onEndCall={handleEndCall}\n onAlwaysOnToggle={handleAlwaysOnToggle}\n isMuted={isMuted}\n isAlwaysOn={isAlwaysOn}\n isPTTActive={isPTTActive}\n disabled={!isCallActive}\n />\n </View>\n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n justifyContent: 'center',\n padding: 20,\n backgroundColor: '#fff',\n },\n});\n\nexport default VoiceCallScreen;\n```\n\n## Key Features:\n\n1. **Push-to-Talk (PTT)**:\n - Large central button with onPressIn/onPressOut handlers\n - Visual feedback with scaling animation\n - Haptic feedback on press/release\n - Status indicator when active\n\n2. **Mute Toggle**:\n - Toggle microphone on/off\n - Visual state indication\n - Icon changes based on mute state\n\n3. **End Call**:\n - Red button for ending calls\n - Distinctive haptic pattern\n - Positioned for easy access\n\n4. **Always-On Toggle**:\n - Enables continuous voice transmission\n - Overrides PTT functionality when active\n - Clear visual indication of state\n\n5. **Visual Feedback**:\n - Animated PTT button scaling\n - Color-coded states\n - Status indicators\n - Disabled state handling\n\n6. **Accessibility**:\n - Clear button labels\n - Appropriate touch targets\n - Visual state indicators\n - Haptic feedback\n\nThe component provides a complete voice control interface suitable for walkie-talkie apps, voice chat, or communication tools."
}Task ID: 3ab84167-e3a0-4b4c-b1a6-eda4d6422d13