feat(app): Trigger-CRUD-Section in Settings + nested-Scroll-Fix
Settings hatte zwei Probleme:
1) Gedächtnis-Liste scrollte nur runter, nicht hoch. Klassisches Android
nested-Scroll-Problem: aeussere ScrollView + innere FlatList mit
fixer height:600 = nur eine Richtung wird respektiert.
Fix: outer ScrollView mit scrollEnabled=false wenn die Section eine
eigene voll-hoch-scrollende Sub-Liste hat (memory/triggers). Plus
dynamische Hoehe via useWindowDimensions (winHeight - 220 statt
hardcoded 600) damit MemoryBrowser sauber den verfuegbaren Platz
nutzt.
2) Trigger waren bisher nur via Diagnostic-Tab editierbar — keine App-
side CRUD. Stefan wollte das.
Neu: TriggerBrowser-Komponente (analog MemoryBrowser-Struktur)
- Liste aller Trigger mit Filter (alle/aktive/inaktive)
- Toggle aktiv/inaktiv via Switch direkt in der Zeile
- Tap oeffnet TriggerEditModal (Nachricht/Condition/fires_at/intervals
editieren, Loeschen-Knopf mit Confirm)
- "+ Neu"-Knopf oeffnet TriggerNewModal mit Type-Switch (Watcher/Timer),
Watcher zeigt Hinweis auf verfuegbare Funktionen + Variablen
- Live Reload-Button, Meta-Info (fire_count, last_fired_at, ...)
brainApi um Trigger-Endpoints erweitert: listTriggers, getTrigger,
createTimer, createWatcher, updateTrigger (patch), deleteTrigger,
getTriggerConditions, getTriggerLogs. Plus Trigger-Type-Definition.
Settings-Liste hat eine neue Section "⏰ Trigger" zwischen Gedaechtnis
und Protokoll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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]);
|
||||
|
||||
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;
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
PermissionsAndroid,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
import audioService from '../services/audio';
|
||||
import gpsTrackingService from '../services/gpsTracking';
|
||||
import MemoryBrowser from '../components/MemoryBrowser';
|
||||
import TriggerBrowser from '../components/TriggerBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -102,6 +104,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||
] as const;
|
||||
@@ -118,6 +121,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
||||
// --- Komponente ---
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const winDims = useWindowDimensions();
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
const [manualHost, setManualHost] = useState('');
|
||||
@@ -868,7 +872,15 @@ const SettingsScreen: React.FC = () => {
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled={true}
|
||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
<>
|
||||
@@ -1682,11 +1694,23 @@ const SettingsScreen: React.FC = () => {
|
||||
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||
Tags. Neue Einträge anlegen via "+ Neu".
|
||||
</Text>
|
||||
<View style={{height: 600, marginBottom: 8}}>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<MemoryBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Trigger === */}
|
||||
{currentSection === 'triggers' && (<>
|
||||
<Text style={styles.sectionTitle}>Trigger</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
|
||||
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<TriggerBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Logs === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
|
||||
@@ -121,6 +121,24 @@ export interface Memory {
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||
export interface Trigger {
|
||||
name: string;
|
||||
type: 'timer' | 'watcher' | string;
|
||||
active: boolean;
|
||||
author?: string;
|
||||
message: string;
|
||||
fires_at?: string; // ISO, nur timer
|
||||
condition?: string; // nur watcher
|
||||
check_interval_sec?: number; // nur watcher
|
||||
throttle_sec?: number; // nur watcher
|
||||
fire_count?: number;
|
||||
last_fired_at?: string | null;
|
||||
last_checked_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export const brainApi = {
|
||||
@@ -215,6 +233,74 @@ export const brainApi = {
|
||||
{ expectBinary: true, timeoutMs: 60000 },
|
||||
);
|
||||
},
|
||||
|
||||
// ── Triggers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Trigger (aktive + inaktive). */
|
||||
listTriggers(): Promise<Trigger[]> {
|
||||
return _send('/triggers/list');
|
||||
},
|
||||
|
||||
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||
getTrigger(name: string): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
|
||||
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
|
||||
return _send('/triggers/conditions');
|
||||
},
|
||||
|
||||
/** Trigger-Logs (last N Feuerungen). */
|
||||
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
|
||||
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
|
||||
return _send('/triggers/timer', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Watcher anlegen. */
|
||||
createWatcher(body: {
|
||||
name: string;
|
||||
condition: string;
|
||||
message: string;
|
||||
check_interval_sec?: number;
|
||||
throttle_sec?: number;
|
||||
author?: string;
|
||||
}): Promise<Trigger> {
|
||||
return _send('/triggers/watcher', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
|
||||
updateTrigger(name: string, body: Partial<{
|
||||
active: boolean;
|
||||
message: string;
|
||||
condition: string;
|
||||
throttle_sec: number;
|
||||
check_interval_sec: number;
|
||||
fires_at: string;
|
||||
}>): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger loeschen. */
|
||||
deleteTrigger(name: string): Promise<{ deleted: string }> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
Reference in New Issue
Block a user