User-Feedback nach Live-Test:
Frontend (mail page):
- HalfDonut als shared component in components/common/HalfDonut.tsx
extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt
jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der
Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick.
Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend
rechts daneben sitzt).
- Donut zeigt Total in der Mitte mit kompaktem Format:
< 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+"
Headline-Zahl oben links entfällt — Total ist im Donut-Center.
- "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible
zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested
Collapsible "Kürzlich blockiert" darunter (default zu).
- Account-Card Expanded: per-Connection-Bar-Chart mit adaptive
Granularität nach Connection-Age:
· <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h"
· 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day
?connectionId=)
· 15-90d → Week-Buckets (client-aggregiert)
· >90d → Month-Buckets (client-aggregiert)
- Settings-Sheet komplett refactored: State-Machine `mode: 'list' |
'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline-
Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern).
Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat).
- Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows
auf chevron-forward (Memory-Konvention).
Frontend (MailAccountCard status fix):
- resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting"
wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt:
"waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan
existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach
Connect) zeigen direkt "live".
- Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook
obwohl Daemon-Log "connected, auth=xoauth2" zeigt.
Backend (imap-idle daemon):
- getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt.
- Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos
hängt nach erfolgreichem connect — die Session bleibt offen ohne
Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt.
Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift
sauber + last_connect_error mit klarem Text (statt stiller Hänger).
- Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach
Deploy in Logs (mo).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
248 lines
8.0 KiB
TypeScript
248 lines
8.0 KiB
TypeScript
import { ReactNode, useState } from 'react';
|
|
import {
|
|
Keyboard,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useColors } from '../lib/theme';
|
|
|
|
export type SheetField = {
|
|
key: string;
|
|
label: string;
|
|
placeholder?: string;
|
|
value: string;
|
|
onChangeText: (v: string) => void;
|
|
validate?: (v: string) => string | undefined;
|
|
normalize?: (v: string) => string;
|
|
keyboardType?: TextInput['props']['keyboardType'];
|
|
secureTextEntry?: boolean;
|
|
autoCapitalize?: TextInput['props']['autoCapitalize'];
|
|
autoCorrect?: boolean;
|
|
suffix?: ReactNode;
|
|
};
|
|
|
|
type Props = {
|
|
fields: SheetField[];
|
|
/**
|
|
* Immer sichtbarer Bereich über Chips + aktivem Input — für Hinweise, die der User
|
|
* sehen soll BEVOR er tippt. Wird in einer eigenen ScrollView mit `flexShrink:1`
|
|
* gerendert, sodass er bei kleinem verfügbaren Platz (Tastatur offen) schrumpft,
|
|
* der Eingabebereich aber nie weggedrückt wird.
|
|
*/
|
|
intro?: ReactNode;
|
|
/** Rendert sich nach dem letzten Feld — sichtbar sobald alle Felder ausgefüllt sind. */
|
|
children?: ReactNode;
|
|
onComplete?: () => void;
|
|
};
|
|
|
|
/**
|
|
* Progressives Multi-Input-Pattern für FormSheet-Inhalte.
|
|
*
|
|
* Jeweils ein Feld ist aktiv (TextInput + →/✓-Button). Bereits ausgefüllte
|
|
* Felder wandern als antippbare Chips nach oben. Tap auf Chip → zurück zum
|
|
* Editieren. Nach dem letzten Feld: Keyboard.dismiss() + children werden sichtbar.
|
|
*
|
|
* Wird als `children` von `<FormSheet>` benutzt.
|
|
*/
|
|
export function SheetFieldStack({ fields, intro, children, onComplete }: Props) {
|
|
const colors = useColors();
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
const allDone = activeIndex >= fields.length;
|
|
|
|
function advanceOrFinish() {
|
|
const field = fields[activeIndex];
|
|
if (!field) return;
|
|
|
|
const error = field.validate?.(field.value);
|
|
if (error) {
|
|
setFieldErrors((prev) => ({ ...prev, [field.key]: error }));
|
|
return;
|
|
}
|
|
|
|
const normalized = field.normalize ? field.normalize(field.value) : field.value;
|
|
if (normalized !== field.value) {
|
|
field.onChangeText(normalized);
|
|
}
|
|
|
|
setFieldErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[field.key];
|
|
return next;
|
|
});
|
|
|
|
if (activeIndex === fields.length - 1) {
|
|
Keyboard.dismiss();
|
|
setActiveIndex(fields.length);
|
|
onComplete?.();
|
|
} else {
|
|
setActiveIndex((i) => i + 1);
|
|
}
|
|
}
|
|
|
|
function goToField(index: number) {
|
|
setActiveIndex(index);
|
|
}
|
|
|
|
const isLast = activeIndex === fields.length - 1;
|
|
|
|
return (
|
|
<View style={{ flex: 1 }}>
|
|
{/* Intro: immer sichtbar, schrumpft bei wenig Platz (Tastatur offen) */}
|
|
{intro != null && (
|
|
<ScrollView
|
|
style={{ flexShrink: 1 }}
|
|
contentContainerStyle={{ padding: 16, paddingBottom: 0 }}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{intro}
|
|
</ScrollView>
|
|
)}
|
|
|
|
{/* Chips + aktives Feld + Post-Completion-Inhalt */}
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{ padding: 16, paddingTop: intro != null ? 10 : 16, gap: 10 }}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Abgeschlossene Felder als Chips */}
|
|
{fields.slice(0, activeIndex).map((field, index) => (
|
|
<TouchableOpacity
|
|
key={field.key}
|
|
activeOpacity={0.7}
|
|
onPress={() => goToField(index)}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surface,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 10,
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}
|
|
>
|
|
{field.label}
|
|
</Text>
|
|
<Text
|
|
style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.text, marginTop: 1 }}
|
|
numberOfLines={1}
|
|
>
|
|
{field.secureTextEntry ? '••••••••' : field.value}
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
))}
|
|
|
|
{/* Aktives Feld */}
|
|
{!allDone && (
|
|
<View>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
{fields[activeIndex].label}
|
|
</Text>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 14,
|
|
borderWidth: fieldErrors[fields[activeIndex].key] ? 1 : 0,
|
|
borderColor: '#dc2626',
|
|
}}
|
|
>
|
|
<TextInput
|
|
autoFocus
|
|
value={fields[activeIndex].value}
|
|
onChangeText={(v) => {
|
|
fields[activeIndex].onChangeText(v);
|
|
if (fieldErrors[fields[activeIndex].key]) {
|
|
setFieldErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[fields[activeIndex].key];
|
|
return next;
|
|
});
|
|
}
|
|
}}
|
|
placeholder={fields[activeIndex].placeholder}
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType={fields[activeIndex].keyboardType ?? 'default'}
|
|
secureTextEntry={fields[activeIndex].secureTextEntry}
|
|
autoCapitalize={fields[activeIndex].autoCapitalize ?? 'sentences'}
|
|
autoCorrect={fields[activeIndex].autoCorrect ?? true}
|
|
returnKeyType={isLast ? 'done' : 'next'}
|
|
onSubmitEditing={advanceOrFinish}
|
|
blurOnSubmit={false}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 12,
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.text,
|
|
}}
|
|
/>
|
|
{fields[activeIndex].suffix}
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
activeOpacity={0.8}
|
|
onPress={advanceOrFinish}
|
|
style={{
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
backgroundColor: colors.brandOrange,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={isLast ? 'checkmark' : 'arrow-forward'}
|
|
size={20}
|
|
color="#fff"
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{fieldErrors[fields[activeIndex].key] && (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#dc2626',
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{fieldErrors[fields[activeIndex].key]}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Rest des Formulars — sichtbar wenn alle Felder durch */}
|
|
{allDone && children}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|