| .. | .. |
|---|
| 1 | | -import React, { useCallback, useState } from "react"; |
|---|
| 2 | | -import { Pressable, Text, View } from "react-native"; |
|---|
| 1 | +import React, { useCallback, useEffect, useRef, useState } from "react"; |
|---|
| 2 | +import { ActionSheetIOS, Alert, KeyboardAvoidingView, Platform, Pressable, Text, View } from "react-native"; |
|---|
| 3 | 3 | import { SafeAreaView } from "react-native-safe-area-context"; |
|---|
| 4 | 4 | import { router } from "expo-router"; |
|---|
| 5 | 5 | import { useChat } from "../contexts/ChatContext"; |
|---|
| 6 | 6 | import { useConnection } from "../contexts/ConnectionContext"; |
|---|
| 7 | +import { useTheme } from "../contexts/ThemeContext"; |
|---|
| 7 | 8 | import { MessageList } from "../components/chat/MessageList"; |
|---|
| 8 | 9 | import { InputBar } from "../components/chat/InputBar"; |
|---|
| 9 | 10 | import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar"; |
|---|
| 11 | +import { ImageCaptionModal } from "../components/chat/ImageCaptionModal"; |
|---|
| 10 | 12 | import { StatusDot } from "../components/ui/StatusDot"; |
|---|
| 11 | | -import { SessionPicker } from "../components/SessionPicker"; |
|---|
| 12 | | -import { playAudio } from "../services/audio"; |
|---|
| 13 | +import { SessionDrawer } from "../components/SessionDrawer"; |
|---|
| 14 | +import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio"; |
|---|
| 15 | + |
|---|
| 16 | +interface StagedImage { |
|---|
| 17 | + base64: string; |
|---|
| 18 | + uri: string; |
|---|
| 19 | + mimeType: string; |
|---|
| 20 | +} |
|---|
| 13 | 21 | |
|---|
| 14 | 22 | export default function ChatScreen() { |
|---|
| 15 | | - const { messages, sendTextMessage, clearMessages, requestScreenshot } = |
|---|
| 23 | + const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } = |
|---|
| 16 | 24 | useChat(); |
|---|
| 17 | 25 | const { status } = useConnection(); |
|---|
| 26 | + const { colors, mode, cycleMode } = useTheme(); |
|---|
| 27 | + const themeIcon = mode === "dark" ? "🌙" : mode === "light" ? "☀️" : "📱"; |
|---|
| 28 | + const activeSessionName = sessions.find((s) => s.isActive)?.name ?? "PAILot"; |
|---|
| 18 | 29 | const [isTextMode, setIsTextMode] = useState(false); |
|---|
| 19 | 30 | const [showSessions, setShowSessions] = useState(false); |
|---|
| 31 | + const [audioPlaying, setAudioPlaying] = useState(false); |
|---|
| 32 | + const [stagedImage, setStagedImage] = useState<StagedImage | null>(null); |
|---|
| 20 | 33 | |
|---|
| 21 | | - const handleSessions = useCallback(() => { |
|---|
| 22 | | - setShowSessions(true); |
|---|
| 34 | + useEffect(() => { |
|---|
| 35 | + return onPlayingChange(setAudioPlaying); |
|---|
| 23 | 36 | }, []); |
|---|
| 24 | 37 | |
|---|
| 25 | 38 | const handleScreenshot = useCallback(() => { |
|---|
| 26 | 39 | requestScreenshot(); |
|---|
| 27 | | - router.push("/navigate"); |
|---|
| 28 | 40 | }, [requestScreenshot]); |
|---|
| 29 | 41 | |
|---|
| 30 | 42 | const handleHelp = useCallback(() => { |
|---|
| .. | .. |
|---|
| 39 | 51 | clearMessages(); |
|---|
| 40 | 52 | }, [clearMessages]); |
|---|
| 41 | 53 | |
|---|
| 54 | + // Resolve a picked asset into a StagedImage |
|---|
| 55 | + const stageAsset = useCallback(async (asset: { base64?: string | null; uri: string; mimeType?: string | null }) => { |
|---|
| 56 | + const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg"); |
|---|
| 57 | + let base64 = asset.base64 ?? ""; |
|---|
| 58 | + if (!base64 && asset.uri) { |
|---|
| 59 | + const { readAsStringAsync } = await import("expo-file-system/legacy"); |
|---|
| 60 | + base64 = await readAsStringAsync(asset.uri, { encoding: "base64" }); |
|---|
| 61 | + } |
|---|
| 62 | + if (base64) { |
|---|
| 63 | + setStagedImage({ base64, uri: asset.uri, mimeType }); |
|---|
| 64 | + } |
|---|
| 65 | + }, []); |
|---|
| 66 | + |
|---|
| 67 | + const pickFromLibrary = useCallback(async () => { |
|---|
| 68 | + try { |
|---|
| 69 | + const ImagePicker = await import("expo-image-picker"); |
|---|
| 70 | + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); |
|---|
| 71 | + if (status !== "granted") { |
|---|
| 72 | + Alert.alert("Permission needed", "Please allow photo library access in Settings."); |
|---|
| 73 | + return; |
|---|
| 74 | + } |
|---|
| 75 | + const result = await ImagePicker.launchImageLibraryAsync({ |
|---|
| 76 | + mediaTypes: ["images"], |
|---|
| 77 | + quality: 0.7, |
|---|
| 78 | + base64: true, |
|---|
| 79 | + }); |
|---|
| 80 | + if (result.canceled || !result.assets?.[0]) return; |
|---|
| 81 | + await stageAsset(result.assets[0]); |
|---|
| 82 | + } catch (err: any) { |
|---|
| 83 | + Alert.alert("Image Error", err?.message ?? String(err)); |
|---|
| 84 | + } |
|---|
| 85 | + }, [stageAsset]); |
|---|
| 86 | + |
|---|
| 87 | + const pickFromCamera = useCallback(async () => { |
|---|
| 88 | + try { |
|---|
| 89 | + const ImagePicker = await import("expo-image-picker"); |
|---|
| 90 | + const { status } = await ImagePicker.requestCameraPermissionsAsync(); |
|---|
| 91 | + if (status !== "granted") { |
|---|
| 92 | + Alert.alert("Permission needed", "Please allow camera access in Settings."); |
|---|
| 93 | + return; |
|---|
| 94 | + } |
|---|
| 95 | + const result = await ImagePicker.launchCameraAsync({ |
|---|
| 96 | + quality: 0.7, |
|---|
| 97 | + base64: true, |
|---|
| 98 | + }); |
|---|
| 99 | + if (result.canceled || !result.assets?.[0]) return; |
|---|
| 100 | + await stageAsset(result.assets[0]); |
|---|
| 101 | + } catch (err: any) { |
|---|
| 102 | + Alert.alert("Camera Error", err?.message ?? String(err)); |
|---|
| 103 | + } |
|---|
| 104 | + }, [stageAsset]); |
|---|
| 105 | + |
|---|
| 106 | + const handlePickImage = useCallback(() => { |
|---|
| 107 | + if (Platform.OS === "ios") { |
|---|
| 108 | + ActionSheetIOS.showActionSheetWithOptions( |
|---|
| 109 | + { |
|---|
| 110 | + options: ["Cancel", "Take Photo", "Choose from Library"], |
|---|
| 111 | + cancelButtonIndex: 0, |
|---|
| 112 | + }, |
|---|
| 113 | + (index) => { |
|---|
| 114 | + if (index === 1) pickFromCamera(); |
|---|
| 115 | + else if (index === 2) pickFromLibrary(); |
|---|
| 116 | + }, |
|---|
| 117 | + ); |
|---|
| 118 | + } else { |
|---|
| 119 | + // Android: just open library (camera is accessible from there) |
|---|
| 120 | + pickFromLibrary(); |
|---|
| 121 | + } |
|---|
| 122 | + }, [pickFromCamera, pickFromLibrary]); |
|---|
| 123 | + |
|---|
| 124 | + const handleImageSend = useCallback( |
|---|
| 125 | + (caption: string) => { |
|---|
| 126 | + if (!stagedImage) return; |
|---|
| 127 | + sendImageMessage(stagedImage.base64, caption, stagedImage.mimeType); |
|---|
| 128 | + setStagedImage(null); |
|---|
| 129 | + }, |
|---|
| 130 | + [stagedImage, sendImageMessage], |
|---|
| 131 | + ); |
|---|
| 132 | + |
|---|
| 42 | 133 | const handleReplay = useCallback(() => { |
|---|
| 134 | + if (isPlaying()) { |
|---|
| 135 | + stopPlayback(); |
|---|
| 136 | + return; |
|---|
| 137 | + } |
|---|
| 43 | 138 | for (let i = messages.length - 1; i >= 0; i--) { |
|---|
| 44 | 139 | const msg = messages[i]; |
|---|
| 45 | 140 | if (msg.role === "assistant") { |
|---|
| .. | .. |
|---|
| 52 | 147 | }, [messages]); |
|---|
| 53 | 148 | |
|---|
| 54 | 149 | return ( |
|---|
| 55 | | - <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}> |
|---|
| 150 | + <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={["top", "bottom"]}> |
|---|
| 151 | + <KeyboardAvoidingView |
|---|
| 152 | + style={{ flex: 1 }} |
|---|
| 153 | + behavior={Platform.OS === "ios" ? "padding" : undefined} |
|---|
| 154 | + keyboardVerticalOffset={0} |
|---|
| 155 | + > |
|---|
| 56 | 156 | {/* Header */} |
|---|
| 57 | 157 | <View |
|---|
| 58 | 158 | style={{ |
|---|
| .. | .. |
|---|
| 62 | 162 | paddingHorizontal: 16, |
|---|
| 63 | 163 | paddingVertical: 12, |
|---|
| 64 | 164 | borderBottomWidth: 1, |
|---|
| 65 | | - borderBottomColor: "#2E2E45", |
|---|
| 165 | + borderBottomColor: colors.border, |
|---|
| 66 | 166 | }} |
|---|
| 67 | 167 | > |
|---|
| 68 | | - <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}> |
|---|
| 69 | | - <Text |
|---|
| 70 | | - style={{ |
|---|
| 71 | | - color: "#E8E8F0", |
|---|
| 72 | | - fontSize: 22, |
|---|
| 73 | | - fontWeight: "800", |
|---|
| 74 | | - letterSpacing: -0.5, |
|---|
| 75 | | - }} |
|---|
| 168 | + <View style={{ flexDirection: "row", alignItems: "center", flex: 1, gap: 10 }}> |
|---|
| 169 | + <Pressable |
|---|
| 170 | + onPress={() => setShowSessions(true)} |
|---|
| 171 | + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} |
|---|
| 172 | + style={({ pressed }) => ({ |
|---|
| 173 | + width: 36, |
|---|
| 174 | + height: 36, |
|---|
| 175 | + alignItems: "center", |
|---|
| 176 | + justifyContent: "center", |
|---|
| 177 | + borderRadius: 18, |
|---|
| 178 | + backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80", |
|---|
| 179 | + })} |
|---|
| 76 | 180 | > |
|---|
| 77 | | - PAILot |
|---|
| 78 | | - </Text> |
|---|
| 79 | | - <StatusDot status={status} size={8} /> |
|---|
| 181 | + <Text style={{ color: colors.textSecondary, fontSize: 18 }}>☰</Text> |
|---|
| 182 | + </Pressable> |
|---|
| 183 | + <Pressable |
|---|
| 184 | + onPress={() => setShowSessions(true)} |
|---|
| 185 | + style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }} |
|---|
| 186 | + hitSlop={{ top: 6, bottom: 6, left: 0, right: 6 }} |
|---|
| 187 | + > |
|---|
| 188 | + <Text |
|---|
| 189 | + style={{ |
|---|
| 190 | + color: colors.text, |
|---|
| 191 | + fontSize: 22, |
|---|
| 192 | + fontWeight: "800", |
|---|
| 193 | + letterSpacing: -0.5, |
|---|
| 194 | + flexShrink: 1, |
|---|
| 195 | + }} |
|---|
| 196 | + numberOfLines={1} |
|---|
| 197 | + > |
|---|
| 198 | + {activeSessionName} |
|---|
| 199 | + </Text> |
|---|
| 200 | + <StatusDot status={status} size={8} /> |
|---|
| 201 | + </Pressable> |
|---|
| 80 | 202 | </View> |
|---|
| 81 | 203 | |
|---|
| 82 | | - <Pressable |
|---|
| 83 | | - onPress={() => router.push("/settings")} |
|---|
| 84 | | - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} |
|---|
| 85 | | - style={{ |
|---|
| 86 | | - width: 36, |
|---|
| 87 | | - height: 36, |
|---|
| 88 | | - alignItems: "center", |
|---|
| 89 | | - justifyContent: "center", |
|---|
| 90 | | - borderRadius: 18, |
|---|
| 91 | | - backgroundColor: "#1E1E2E", |
|---|
| 92 | | - }} |
|---|
| 93 | | - > |
|---|
| 94 | | - <Text style={{ fontSize: 15 }}>⚙️</Text> |
|---|
| 95 | | - </Pressable> |
|---|
| 204 | + <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}> |
|---|
| 205 | + <Pressable |
|---|
| 206 | + onPress={cycleMode} |
|---|
| 207 | + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} |
|---|
| 208 | + style={({ pressed }) => ({ |
|---|
| 209 | + width: 36, |
|---|
| 210 | + height: 36, |
|---|
| 211 | + alignItems: "center", |
|---|
| 212 | + justifyContent: "center", |
|---|
| 213 | + borderRadius: 18, |
|---|
| 214 | + backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80", |
|---|
| 215 | + })} |
|---|
| 216 | + > |
|---|
| 217 | + <Text style={{ fontSize: 15 }}>{themeIcon}</Text> |
|---|
| 218 | + </Pressable> |
|---|
| 219 | + <Pressable |
|---|
| 220 | + onPress={() => router.push("/settings")} |
|---|
| 221 | + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} |
|---|
| 222 | + style={{ |
|---|
| 223 | + width: 36, |
|---|
| 224 | + height: 36, |
|---|
| 225 | + alignItems: "center", |
|---|
| 226 | + justifyContent: "center", |
|---|
| 227 | + borderRadius: 18, |
|---|
| 228 | + backgroundColor: colors.bgTertiary, |
|---|
| 229 | + }} |
|---|
| 230 | + > |
|---|
| 231 | + <Text style={{ fontSize: 15 }}>⚙️</Text> |
|---|
| 232 | + </Pressable> |
|---|
| 233 | + </View> |
|---|
| 96 | 234 | </View> |
|---|
| 97 | 235 | |
|---|
| 98 | 236 | {/* Message list */} |
|---|
| .. | .. |
|---|
| 104 | 242 | width: 80, |
|---|
| 105 | 243 | height: 80, |
|---|
| 106 | 244 | borderRadius: 40, |
|---|
| 107 | | - backgroundColor: "#1E1E2E", |
|---|
| 245 | + backgroundColor: colors.bgTertiary, |
|---|
| 108 | 246 | alignItems: "center", |
|---|
| 109 | 247 | justifyContent: "center", |
|---|
| 110 | 248 | borderWidth: 1, |
|---|
| 111 | | - borderColor: "#2E2E45", |
|---|
| 249 | + borderColor: colors.border, |
|---|
| 112 | 250 | }} |
|---|
| 113 | 251 | > |
|---|
| 114 | 252 | <Text style={{ fontSize: 36 }}>🛩</Text> |
|---|
| 115 | 253 | </View> |
|---|
| 116 | 254 | <View style={{ alignItems: "center", gap: 6 }}> |
|---|
| 117 | | - <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}> |
|---|
| 255 | + <Text style={{ color: colors.text, fontSize: 20, fontWeight: "700" }}> |
|---|
| 118 | 256 | PAILot |
|---|
| 119 | 257 | </Text> |
|---|
| 120 | 258 | <Text |
|---|
| 121 | 259 | style={{ |
|---|
| 122 | | - color: "#5A5A78", |
|---|
| 260 | + color: colors.textMuted, |
|---|
| 123 | 261 | fontSize: 14, |
|---|
| 124 | 262 | textAlign: "center", |
|---|
| 125 | 263 | paddingHorizontal: 40, |
|---|
| .. | .. |
|---|
| 138 | 276 | {/* Command bar */} |
|---|
| 139 | 277 | {isTextMode ? ( |
|---|
| 140 | 278 | <TextModeCommandBar |
|---|
| 141 | | - onSessions={handleSessions} |
|---|
| 142 | 279 | onScreenshot={handleScreenshot} |
|---|
| 143 | 280 | onNavigate={handleNavigate} |
|---|
| 281 | + onPhoto={handlePickImage} |
|---|
| 282 | + onHelp={handleHelp} |
|---|
| 144 | 283 | onClear={handleClear} |
|---|
| 145 | 284 | /> |
|---|
| 146 | 285 | ) : ( |
|---|
| 147 | 286 | <CommandBar |
|---|
| 148 | | - onSessions={handleSessions} |
|---|
| 149 | 287 | onScreenshot={handleScreenshot} |
|---|
| 150 | | - onHelp={handleHelp} |
|---|
| 288 | + onNavigate={handleNavigate} |
|---|
| 289 | + onPhoto={handlePickImage} |
|---|
| 290 | + onClear={handleClear} |
|---|
| 151 | 291 | /> |
|---|
| 152 | 292 | )} |
|---|
| 153 | 293 | |
|---|
| 154 | 294 | {/* Input bar */} |
|---|
| 155 | 295 | <InputBar |
|---|
| 156 | 296 | onSendText={sendTextMessage} |
|---|
| 297 | + onVoiceRecorded={sendVoiceMessage} |
|---|
| 157 | 298 | onReplay={handleReplay} |
|---|
| 158 | 299 | isTextMode={isTextMode} |
|---|
| 159 | 300 | onToggleMode={() => setIsTextMode((v) => !v)} |
|---|
| 301 | + audioPlaying={audioPlaying} |
|---|
| 160 | 302 | /> |
|---|
| 161 | 303 | |
|---|
| 162 | | - {/* Session picker modal */} |
|---|
| 163 | | - <SessionPicker |
|---|
| 164 | | - visible={showSessions} |
|---|
| 165 | | - onClose={() => setShowSessions(false)} |
|---|
| 166 | | - /> |
|---|
| 304 | + </KeyboardAvoidingView> |
|---|
| 305 | + |
|---|
| 306 | + {/* Image caption modal — WhatsApp-style full-screen preview */} |
|---|
| 307 | + <ImageCaptionModal |
|---|
| 308 | + visible={!!stagedImage} |
|---|
| 309 | + imageUri={stagedImage ? `data:${stagedImage.mimeType};base64,${stagedImage.base64}` : ""} |
|---|
| 310 | + onSend={handleImageSend} |
|---|
| 311 | + onCancel={() => setStagedImage(null)} |
|---|
| 312 | + /> |
|---|
| 313 | + |
|---|
| 314 | + {/* Session drawer — absolute overlay outside KAV */} |
|---|
| 315 | + <SessionDrawer |
|---|
| 316 | + visible={showSessions} |
|---|
| 317 | + onClose={() => setShowSessions(false)} |
|---|
| 318 | + /> |
|---|
| 167 | 319 | </SafeAreaView> |
|---|
| 168 | 320 | ); |
|---|
| 169 | 321 | } |
|---|