chahinebrini 1dfb0c647c feat(mail-page): polish v3 + shared HalfDonut + status-dot heartbeat-aware
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>
2026-05-13 23:23:45 +02:00

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>
);
}