Files
ARIA-AGENT/android/src/components/TriggerBrowser.tsx
T
duffyduck e82e07e3a2 fix: 5er-Bundle — Wake-Word, Spotify-Latenz, File-Limit, Connection-Refused
- WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event-
  Race + setBackground/setForeground statt setResumeCooldown im AppState.
- Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin
  emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr.
- Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track,
  pause, play, lauter, ...) matchen via Regex und gehen direkt aufs
  spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl.
- File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload +
  Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB.
- TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send
  fast-failt bei disconnected RVS statt 30s zu timeouten, und beide
  UIs reloaden automatisch beim Reconnect-Event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:27:08 +02:00

596 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
*
* Eingesetzt von SettingsScreen → Sektion "Trigger".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Trigger } from '../services/brainApi';
import rvs from '../services/rvs';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_TIMER = '#0096FF';
const COL_WATCHER = '#FFD60A';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const TriggerBrowser: React.FC = () => {
const [items, setItems] = useState<Trigger[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
const [showNew, setShowNew] = useState(false);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listTriggers()
.then(t => {
// Sortierung: aktive zuerst, dann nach Name
t.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(t);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// Auto-Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail-
// Fehler aus brainApi ewig an obwohl die Verbindung schon wieder da ist.
useEffect(() => {
const unsub = rvs.onStateChange((state) => {
if (state === 'connected') {
load();
}
});
return () => unsub();
}, [load]);
const visible = items.filter(t => {
if (filter === 'active') return t.active;
if (filter === 'inactive') return !t.active;
return true;
});
const toggleActive = (t: Trigger) => {
brainApi.updateTrigger(t.name, { active: !t.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const deleteTrigger = (t: Trigger) => {
Alert.alert(
'Trigger löschen?',
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
brainApi.deleteTrigger(t.name)
.then(() => { setEditTrigger(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const renderItem = ({ item }: { item: Trigger }) => {
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
return (
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
{item.type === 'watcher' && item.condition ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
{item.condition}
</Text>
) : null}
{item.type === 'timer' && item.fires_at ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
{/* Filter-Leiste + Reload + Neu */}
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={t => t.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{editTrigger ? (
<TriggerEditModal
trigger={editTrigger}
onClose={() => setEditTrigger(null)}
onSaved={() => { setEditTrigger(null); load(); }}
onDelete={() => deleteTrigger(editTrigger)}
/>
) : null}
{showNew ? (
<TriggerNewModal
onClose={() => setShowNew(false)}
onCreated={() => { setShowNew(false); load(); }}
/>
) : null}
</View>
);
};
// ── Edit-Modal ─────────────────────────────────────────────────────────
interface EditProps {
trigger: Trigger;
onClose: () => void;
onSaved: () => void;
onDelete: () => void;
}
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
const [message, setMessage] = useState(trigger.message || '');
const [condition, setCondition] = useState(trigger.condition || '');
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
const patch: any = { message };
if (trigger.type === 'watcher') {
patch.condition = condition;
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
patch.throttle_sec = parseInt(throttle, 10) || 3600;
} else if (trigger.type === 'timer') {
patch.fires_at = firesAt;
}
brainApi.updateTrigger(trigger.name, patch)
.then(onSaved)
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
placeholderTextColor="#555570"
/>
{trigger.type === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
</>
)}
<View style={s.metaBox}>
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
</View>
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// ── Neu-Modal ──────────────────────────────────────────────────────────
interface NewProps {
onClose: () => void;
onCreated: () => void;
}
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [condition, setCondition] = useState('');
const [firesAt, setFiresAt] = useState('');
const [checkInterval, setCheckInterval] = useState('300');
const [throttle, setThrottle] = useState('3600');
const [saving, setSaving] = useState(false);
const create = () => {
if (!name.trim() || !message.trim()) {
Alert.alert('Name und Nachricht erforderlich');
return;
}
setSaving(true);
const promise = ttype === 'timer'
? brainApi.createTimer({
name: name.trim(),
fires_at: firesAt.trim(),
message: message.trim(),
})
: brainApi.createWatcher({
name: name.trim(),
condition: condition.trim(),
message: message.trim(),
check_interval_sec: parseInt(checkInterval, 10) || 300,
throttle_sec: parseInt(throttle, 10) || 3600,
});
promise
.then(onCreated)
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Typ</Text>
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
{(['watcher', 'timer'] as const).map(t => (
<TouchableOpacity
key={t}
onPress={() => setTtype(t)}
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
>
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.label}>Name (kebab-case)</Text>
<TextInput
style={s.input}
value={name}
onChangeText={setName}
placeholder="z.B. drk-kreyenbrueck-warnung"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen?"
placeholderTextColor="#555570"
/>
{ttype === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. entered_near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
</Text>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
</>
)}
</ScrollView>
<View style={s.modalFooter}>
<View style={{flex: 1}} />
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
err: {
color: '#FF3B30',
padding: 12,
fontSize: 12,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#1A1A2E',
borderRadius: 8,
marginBottom: 6,
},
modalBg: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
modal: {
backgroundColor: '#0D0D1A',
borderRadius: 12,
width: '100%',
maxWidth: 600,
maxHeight: '90%',
borderWidth: 1,
borderColor: '#1E1E2E',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
marginBottom: 8,
},
hint: {
color: '#555570',
fontSize: 11,
fontStyle: 'italic',
marginTop: -4,
marginBottom: 10,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 10,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default TriggerBrowser;