From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support

---
 app/chat.tsx |  246 +++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 199 insertions(+), 47 deletions(-)

diff --git a/app/chat.tsx b/app/chat.tsx
index d773e98..49f93bc 100644
--- a/app/chat.tsx
+++ b/app/chat.tsx
@@ -1,30 +1,42 @@
-import React, { useCallback, useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { ActionSheetIOS, Alert, KeyboardAvoidingView, Platform, Pressable, Text, View } from "react-native";
 import { SafeAreaView } from "react-native-safe-area-context";
 import { router } from "expo-router";
 import { useChat } from "../contexts/ChatContext";
 import { useConnection } from "../contexts/ConnectionContext";
+import { useTheme } from "../contexts/ThemeContext";
 import { MessageList } from "../components/chat/MessageList";
 import { InputBar } from "../components/chat/InputBar";
 import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
+import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
 import { StatusDot } from "../components/ui/StatusDot";
-import { SessionPicker } from "../components/SessionPicker";
-import { playAudio } from "../services/audio";
+import { SessionDrawer } from "../components/SessionDrawer";
+import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
+
+interface StagedImage {
+  base64: string;
+  uri: string;
+  mimeType: string;
+}
 
 export default function ChatScreen() {
-  const { messages, sendTextMessage, clearMessages, requestScreenshot } =
+  const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } =
     useChat();
   const { status } = useConnection();
+  const { colors, mode, cycleMode } = useTheme();
+  const themeIcon = mode === "dark" ? "🌙" : mode === "light" ? "☀️" : "📱";
+  const activeSessionName = sessions.find((s) => s.isActive)?.name ?? "PAILot";
   const [isTextMode, setIsTextMode] = useState(false);
   const [showSessions, setShowSessions] = useState(false);
+  const [audioPlaying, setAudioPlaying] = useState(false);
+  const [stagedImage, setStagedImage] = useState<StagedImage | null>(null);
 
-  const handleSessions = useCallback(() => {
-    setShowSessions(true);
+  useEffect(() => {
+    return onPlayingChange(setAudioPlaying);
   }, []);
 
   const handleScreenshot = useCallback(() => {
     requestScreenshot();
-    router.push("/navigate");
   }, [requestScreenshot]);
 
   const handleHelp = useCallback(() => {
@@ -39,7 +51,90 @@
     clearMessages();
   }, [clearMessages]);
 
+  // Resolve a picked asset into a StagedImage
+  const stageAsset = useCallback(async (asset: { base64?: string | null; uri: string; mimeType?: string | null }) => {
+    const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg");
+    let base64 = asset.base64 ?? "";
+    if (!base64 && asset.uri) {
+      const { readAsStringAsync } = await import("expo-file-system/legacy");
+      base64 = await readAsStringAsync(asset.uri, { encoding: "base64" });
+    }
+    if (base64) {
+      setStagedImage({ base64, uri: asset.uri, mimeType });
+    }
+  }, []);
+
+  const pickFromLibrary = useCallback(async () => {
+    try {
+      const ImagePicker = await import("expo-image-picker");
+      const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
+      if (status !== "granted") {
+        Alert.alert("Permission needed", "Please allow photo library access in Settings.");
+        return;
+      }
+      const result = await ImagePicker.launchImageLibraryAsync({
+        mediaTypes: ["images"],
+        quality: 0.7,
+        base64: true,
+      });
+      if (result.canceled || !result.assets?.[0]) return;
+      await stageAsset(result.assets[0]);
+    } catch (err: any) {
+      Alert.alert("Image Error", err?.message ?? String(err));
+    }
+  }, [stageAsset]);
+
+  const pickFromCamera = useCallback(async () => {
+    try {
+      const ImagePicker = await import("expo-image-picker");
+      const { status } = await ImagePicker.requestCameraPermissionsAsync();
+      if (status !== "granted") {
+        Alert.alert("Permission needed", "Please allow camera access in Settings.");
+        return;
+      }
+      const result = await ImagePicker.launchCameraAsync({
+        quality: 0.7,
+        base64: true,
+      });
+      if (result.canceled || !result.assets?.[0]) return;
+      await stageAsset(result.assets[0]);
+    } catch (err: any) {
+      Alert.alert("Camera Error", err?.message ?? String(err));
+    }
+  }, [stageAsset]);
+
+  const handlePickImage = useCallback(() => {
+    if (Platform.OS === "ios") {
+      ActionSheetIOS.showActionSheetWithOptions(
+        {
+          options: ["Cancel", "Take Photo", "Choose from Library"],
+          cancelButtonIndex: 0,
+        },
+        (index) => {
+          if (index === 1) pickFromCamera();
+          else if (index === 2) pickFromLibrary();
+        },
+      );
+    } else {
+      // Android: just open library (camera is accessible from there)
+      pickFromLibrary();
+    }
+  }, [pickFromCamera, pickFromLibrary]);
+
+  const handleImageSend = useCallback(
+    (caption: string) => {
+      if (!stagedImage) return;
+      sendImageMessage(stagedImage.base64, caption, stagedImage.mimeType);
+      setStagedImage(null);
+    },
+    [stagedImage, sendImageMessage],
+  );
+
   const handleReplay = useCallback(() => {
+    if (isPlaying()) {
+      stopPlayback();
+      return;
+    }
     for (let i = messages.length - 1; i >= 0; i--) {
       const msg = messages[i];
       if (msg.role === "assistant") {
@@ -52,7 +147,12 @@
   }, [messages]);
 
   return (
-    <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
+    <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={["top", "bottom"]}>
+    <KeyboardAvoidingView
+      style={{ flex: 1 }}
+      behavior={Platform.OS === "ios" ? "padding" : undefined}
+      keyboardVerticalOffset={0}
+    >
       {/* Header */}
       <View
         style={{
@@ -62,37 +162,75 @@
           paddingHorizontal: 16,
           paddingVertical: 12,
           borderBottomWidth: 1,
-          borderBottomColor: "#2E2E45",
+          borderBottomColor: colors.border,
         }}
       >
-        <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
-          <Text
-            style={{
-              color: "#E8E8F0",
-              fontSize: 22,
-              fontWeight: "800",
-              letterSpacing: -0.5,
-            }}
+        <View style={{ flexDirection: "row", alignItems: "center", flex: 1, gap: 10 }}>
+          <Pressable
+            onPress={() => setShowSessions(true)}
+            hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+            style={({ pressed }) => ({
+              width: 36,
+              height: 36,
+              alignItems: "center",
+              justifyContent: "center",
+              borderRadius: 18,
+              backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
+            })}
           >
-            PAILot
-          </Text>
-          <StatusDot status={status} size={8} />
+            <Text style={{ color: colors.textSecondary, fontSize: 18 }}>☰</Text>
+          </Pressable>
+          <Pressable
+            onPress={() => setShowSessions(true)}
+            style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}
+            hitSlop={{ top: 6, bottom: 6, left: 0, right: 6 }}
+          >
+            <Text
+              style={{
+                color: colors.text,
+                fontSize: 22,
+                fontWeight: "800",
+                letterSpacing: -0.5,
+                flexShrink: 1,
+              }}
+              numberOfLines={1}
+            >
+              {activeSessionName}
+            </Text>
+            <StatusDot status={status} size={8} />
+          </Pressable>
         </View>
 
-        <Pressable
-          onPress={() => router.push("/settings")}
-          hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
-          style={{
-            width: 36,
-            height: 36,
-            alignItems: "center",
-            justifyContent: "center",
-            borderRadius: 18,
-            backgroundColor: "#1E1E2E",
-          }}
-        >
-          <Text style={{ fontSize: 15 }}>⚙️</Text>
-        </Pressable>
+        <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
+          <Pressable
+            onPress={cycleMode}
+            hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+            style={({ pressed }) => ({
+              width: 36,
+              height: 36,
+              alignItems: "center",
+              justifyContent: "center",
+              borderRadius: 18,
+              backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
+            })}
+          >
+            <Text style={{ fontSize: 15 }}>{themeIcon}</Text>
+          </Pressable>
+          <Pressable
+            onPress={() => router.push("/settings")}
+            hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+            style={{
+              width: 36,
+              height: 36,
+              alignItems: "center",
+              justifyContent: "center",
+              borderRadius: 18,
+              backgroundColor: colors.bgTertiary,
+            }}
+          >
+            <Text style={{ fontSize: 15 }}>⚙️</Text>
+          </Pressable>
+        </View>
       </View>
 
       {/* Message list */}
@@ -104,22 +242,22 @@
                 width: 80,
                 height: 80,
                 borderRadius: 40,
-                backgroundColor: "#1E1E2E",
+                backgroundColor: colors.bgTertiary,
                 alignItems: "center",
                 justifyContent: "center",
                 borderWidth: 1,
-                borderColor: "#2E2E45",
+                borderColor: colors.border,
               }}
             >
               <Text style={{ fontSize: 36 }}>🛩</Text>
             </View>
             <View style={{ alignItems: "center", gap: 6 }}>
-              <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}>
+              <Text style={{ color: colors.text, fontSize: 20, fontWeight: "700" }}>
                 PAILot
               </Text>
               <Text
                 style={{
-                  color: "#5A5A78",
+                  color: colors.textMuted,
                   fontSize: 14,
                   textAlign: "center",
                   paddingHorizontal: 40,
@@ -138,32 +276,46 @@
       {/* Command bar */}
       {isTextMode ? (
         <TextModeCommandBar
-          onSessions={handleSessions}
           onScreenshot={handleScreenshot}
           onNavigate={handleNavigate}
+          onPhoto={handlePickImage}
+          onHelp={handleHelp}
           onClear={handleClear}
         />
       ) : (
         <CommandBar
-          onSessions={handleSessions}
           onScreenshot={handleScreenshot}
-          onHelp={handleHelp}
+          onNavigate={handleNavigate}
+          onPhoto={handlePickImage}
+          onClear={handleClear}
         />
       )}
 
       {/* Input bar */}
       <InputBar
         onSendText={sendTextMessage}
+        onVoiceRecorded={sendVoiceMessage}
         onReplay={handleReplay}
         isTextMode={isTextMode}
         onToggleMode={() => setIsTextMode((v) => !v)}
+        audioPlaying={audioPlaying}
       />
 
-      {/* Session picker modal */}
-      <SessionPicker
-        visible={showSessions}
-        onClose={() => setShowSessions(false)}
-      />
+    </KeyboardAvoidingView>
+
+    {/* Image caption modal — WhatsApp-style full-screen preview */}
+    <ImageCaptionModal
+      visible={!!stagedImage}
+      imageUri={stagedImage ? `data:${stagedImage.mimeType};base64,${stagedImage.base64}` : ""}
+      onSend={handleImageSend}
+      onCancel={() => setStagedImage(null)}
+    />
+
+    {/* Session drawer — absolute overlay outside KAV */}
+    <SessionDrawer
+      visible={showSessions}
+      onClose={() => setShowSessions(false)}
+    />
     </SafeAreaView>
   );
 }

--
Gitblit v1.3.1