feat: App-Chat-Suche mit Next/Prev + Diagnostic Sprachausgabe-Layout
App Chat-Suche umgebaut von Filter zu Highlight+Navigation
Vorher: searchQuery filtert die FlatList, zeigt nur Treffer.
Jetzt: Suche filtert NICHT mehr, alle Nachrichten bleiben sichtbar.
Treffer wird gelb (FFD60A) umrandet, FlatList scrollt automatisch
dorthin.
- Suchleiste: Input + Counter "N/M" + ▲ + ▼ + ✕
- ▲ / ▼ navigieren chronologisch durch alle Matches (zyklisch)
- searchMatchIds via useMemo, searchIndex separates State
- scrollToIndex mit viewPosition: 0.4 (Treffer landet im oberen Drittel)
- onScrollToIndexFailed Fallback nach 200ms (Layout noch nicht fertig)
Diagnostic Sprachausgabe-Layout
Export/Import-Buttons wandern aus dem Section-Header in den Details-Block
neben "Anwenden" (Stefan's Wunsch). Header zeigt nur noch den Titel.
File-Input bleibt versteckt im Section-Top, wird vom neuen Button-Block
unten ueber click() getriggert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||||
@@ -892,6 +893,43 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
||||||
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
|
|
||||||
|
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
||||||
|
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
||||||
|
const searchMatchIds = useMemo(() => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return [] as string[];
|
||||||
|
return messages
|
||||||
|
.filter(m => (m.text || '').toLowerCase().includes(q))
|
||||||
|
.map(m => m.id);
|
||||||
|
}, [messages, searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchIndex(0);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchMatchIds.length) return;
|
||||||
|
const id = searchMatchIds[searchIndex];
|
||||||
|
if (!id) return;
|
||||||
|
// invertedMessages → index in der angezeigten Liste finden
|
||||||
|
const idx = invertedMessages.findIndex(m => m.id === id);
|
||||||
|
if (idx < 0 || !flatListRef.current) return;
|
||||||
|
try {
|
||||||
|
flatListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.4 });
|
||||||
|
} catch {}
|
||||||
|
}, [searchIndex, searchMatchIds, invertedMessages]);
|
||||||
|
|
||||||
|
const activeSearchId = searchMatchIds[searchIndex] || '';
|
||||||
|
const gotoSearchPrev = () => {
|
||||||
|
if (!searchMatchIds.length) return;
|
||||||
|
setSearchIndex(i => (i - 1 + searchMatchIds.length) % searchMatchIds.length);
|
||||||
|
};
|
||||||
|
const gotoSearchNext = () => {
|
||||||
|
if (!searchMatchIds.length) return;
|
||||||
|
setSearchIndex(i => (i + 1) % searchMatchIds.length);
|
||||||
|
};
|
||||||
|
|
||||||
// GPS-Position holen (optional)
|
// GPS-Position holen (optional)
|
||||||
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
||||||
if (!gpsEnabled) {
|
if (!gpsEnabled) {
|
||||||
@@ -1143,12 +1181,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
const isSearchHit = activeSearchId === item.id;
|
||||||
|
const searchHighlightStyle = isSearchHit
|
||||||
|
? { borderWidth: 2, borderColor: '#FFD60A' }
|
||||||
|
: null;
|
||||||
|
|
||||||
// Spezial-Bubble: ARIA hat einen Skill erstellt
|
// Spezial-Bubble: ARIA hat einen Skill erstellt
|
||||||
if (item.skillCreated) {
|
if (item.skillCreated) {
|
||||||
const s = item.skillCreated;
|
const s = item.skillCreated;
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}]}>
|
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
||||||
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||||
{'🛠 ARIA hat einen neuen Skill erstellt'}
|
{'🛠 ARIA hat einen neuen Skill erstellt'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1168,7 +1210,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
|
||||||
{/* Anhang-Vorschau */}
|
{/* Anhang-Vorschau */}
|
||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
@@ -1342,7 +1384,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Suchleiste */}
|
{/* Suchleiste mit Treffer-Navigation */}
|
||||||
{searchVisible && (
|
{searchVisible && (
|
||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -1353,17 +1395,43 @@ const ChatScreen: React.FC = () => {
|
|||||||
placeholderTextColor="#555570"
|
placeholderTextColor="#555570"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{searchQuery ? (
|
||||||
|
<Text style={{color: searchMatchIds.length ? '#0096FF' : '#555570', fontSize: 12, paddingHorizontal: 6}}>
|
||||||
|
{searchMatchIds.length ? `${searchIndex + 1}/${searchMatchIds.length}` : '0/0'}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={gotoSearchPrev}
|
||||||
|
disabled={!searchMatchIds.length}
|
||||||
|
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#0096FF', fontSize: 18}}>{'▲'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={gotoSearchNext}
|
||||||
|
disabled={!searchMatchIds.length}
|
||||||
|
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#0096FF', fontSize: 18}}>{'▼'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
|
<TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
|
||||||
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
|
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nachrichtenliste */}
|
{/* Nachrichtenliste — Suche FILTERT NICHT mehr, sondern hebt aktiven
|
||||||
|
Treffer hervor (siehe renderMessage: activeSearchId-Border). */}
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
inverted
|
inverted
|
||||||
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())).reverse() : invertedMessages}
|
data={invertedMessages}
|
||||||
|
onScrollToIndexFailed={(info) => {
|
||||||
|
// Bei zu schnellem Aufruf vor Layout: einmal nachfassen
|
||||||
|
setTimeout(() => {
|
||||||
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.4 }); } catch {}
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
contentContainerStyle={styles.messageList}
|
contentContainerStyle={styles.messageList}
|
||||||
|
|||||||
+11
-8
@@ -470,14 +470,9 @@
|
|||||||
|
|
||||||
<!-- Stimmen -->
|
<!-- Stimmen -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
<h2>Sprachausgabe</h2>
|
||||||
<h2 style="margin:0;">Sprachausgabe</h2>
|
<!-- file-input fuer Import (versteckt, wird vom Button im Details-Block getriggert) -->
|
||||||
<div style="display:flex;gap:6px;">
|
|
||||||
<button class="btn secondary" onclick="exportVoiceSettings()" style="padding:4px 10px;font-size:11px;" title="voice_config.json + highlight_triggers herunterladen">⬇ Export</button>
|
|
||||||
<input type="file" id="voice-settings-import-file" accept=".json,application/json" style="display:none" onchange="importVoiceSettings(event)">
|
<input type="file" id="voice-settings-import-file" accept=".json,application/json" style="display:none" onchange="importVoiceSettings(event)">
|
||||||
<button class="btn secondary" onclick="document.getElementById('voice-settings-import-file').click()" style="padding:4px 10px;font-size:11px;">⬆ Import</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="max-width:500px;">
|
<div class="card" style="max-width:500px;">
|
||||||
<!-- TTS aktiv (global) -->
|
<!-- TTS aktiv (global) -->
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
||||||
@@ -546,9 +541,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;align-self:flex-start;margin-top:6px;">
|
<div style="display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;">
|
||||||
|
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;">
|
||||||
Anwenden
|
Anwenden
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn secondary" onclick="exportVoiceSettings()" style="padding:6px 14px;font-size:12px;" title="voice_config.json + highlight_triggers als JSON-Bundle herunterladen">
|
||||||
|
⬇ Export
|
||||||
|
</button>
|
||||||
|
<button class="btn secondary" onclick="document.getElementById('voice-settings-import-file').click()" style="padding:6px 14px;font-size:12px;" title="JSON-Bundle einspielen">
|
||||||
|
⬆ Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user