From 7093ebaf0b5c5483408130cab70737f75286e779 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 15 May 2026 22:44:24 +0200 Subject: [PATCH] feat(app): Trigger-CRUD-Section in Settings + nested-Scroll-Fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/src/components/TriggerBrowser.tsx | 583 ++++++++++++++++++++++ android/src/screens/SettingsScreen.tsx | 28 +- android/src/services/brainApi.ts | 86 ++++ 3 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 android/src/components/TriggerBrowser.tsx diff --git a/android/src/components/TriggerBrowser.tsx b/android/src/components/TriggerBrowser.tsx new file mode 100644 index 0000000..6392f21 --- /dev/null +++ b/android/src/components/TriggerBrowser.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [editTrigger, setEditTrigger] = useState(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 ( + setEditTrigger(item)}> + + + {typeLabel} + {item.name} + + {item.message} + {item.type === 'watcher' && item.condition ? ( + + {item.condition} + + ) : null} + {item.type === 'timer' && item.fires_at ? ( + + feuert: {new Date(item.fires_at).toLocaleString('de-DE')} + + ) : null} + + {item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)} + + + toggleActive(item)} + trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }} + thumbColor="#E0E0F0" + /> + + ); + }; + + return ( + + {/* Filter-Leiste + Reload + Neu */} + + {(['all', 'active', 'inactive'] as const).map(f => ( + setFilter(f)} + > + + {f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'} + + + ))} + + + {'↻'} + + setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}> + + Neu + + + + {err ? {err} : null} + + {loading && items.length === 0 ? ( + + ) : ( + t.name} + renderItem={renderItem} + nestedScrollEnabled={true} + ListEmptyComponent={ + + {items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'} + + } + contentContainerStyle={{paddingBottom: 20}} + /> + )} + + {editTrigger ? ( + setEditTrigger(null)} + onSaved={() => { setEditTrigger(null); load(); }} + onDelete={() => deleteTrigger(editTrigger)} + /> + ) : null} + + {showNew ? ( + setShowNew(false)} + onCreated={() => { setShowNew(false); load(); }} + /> + ) : null} + + ); +}; + +// ── Edit-Modal ───────────────────────────────────────────────────────── + +interface EditProps { + trigger: Trigger; + onClose: () => void; + onSaved: () => void; + onDelete: () => void; +} + +const TriggerEditModal: React.FC = ({ 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 ( + + + + + + {trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name} + + + × + + + + Nachricht + + + {trigger.type === 'watcher' ? ( + <> + Condition + + + + Check-Intervall (s) + + + + Throttle (s) + + + + + ) : ( + <> + Feuert am (ISO, UTC) + + + )} + + + Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'} + Gefeuert: {trigger.fire_count || 0}× + Zuletzt gefeuert: {relTime(trigger.last_fired_at)} + Zuletzt geprüft: {relTime(trigger.last_checked_at)} + {trigger.author ? Angelegt von: {trigger.author} : null} + + + + + 🗑 Löschen + + + + {saving ? 'Speichert...' : 'Speichern'} + + + + + + ); +}; + +// ── Neu-Modal ────────────────────────────────────────────────────────── + +interface NewProps { + onClose: () => void; + onCreated: () => void; +} + +const TriggerNewModal: React.FC = ({ 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 ( + + + + + + Neuer Trigger + + × + + + + Typ + + {(['watcher', 'timer'] as const).map(t => ( + setTtype(t)} + style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]} + > + + {t === 'watcher' ? '👁 Watcher' : '⏰ Timer'} + + + ))} + + + Name (kebab-case) + + + Nachricht + + + {ttype === 'watcher' ? ( + <> + Condition + + + Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec + + + + Check-Intervall (s) + + + + Throttle (s) + + + + + ) : ( + <> + Feuert am (ISO, UTC) + + Beispiel oben: heute 20:00 UTC = 22:00 CEST + + )} + + + + + {saving ? 'Legt an...' : 'Anlegen'} + + + + + + ); +}; + +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; diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index e2d5f19..ece205f 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -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 = { // --- Komponente --- const SettingsScreen: React.FC = () => { + const winDims = useWindowDimensions(); const [connectionState, setConnectionState] = useState('disconnected'); const [manualToken, setManualToken] = useState(''); const [manualHost, setManualHost] = useState(''); @@ -868,7 +872,15 @@ const SettingsScreen: React.FC = () => { })()} - + {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". - + )} + {/* === Trigger === */} + {currentSection === 'triggers' && (<> + Trigger + + Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv, + Tap zum Bearbeiten, "+ Neu" zum Anlegen. + + + + + )} + {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index e051594..5f42752 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -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 { + return _send('/triggers/list'); + }, + + /** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */ + getTrigger(name: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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;