- mail.tsx: hide section-header "+" button when accounts.length === 0 — MailEmptyState's CTA is the sole add trigger; also replaces Pressable with TouchableOpacity - MailEmptyState: Pressable → TouchableOpacity (no-Pressable rule) - SheetFieldStack: add optional `intro?: ReactNode` prop rendered in a flexShrink:1 ScrollView above chips/active-input so it compresses gracefully when the keyboard is up - ConnectMailSheet: move app-password guide + green AES block into `intro` prop so they're visible from the start, before the user types anything Co-Authored-By: Claude Sonnet 4.6 <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="pencil-outline" 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>
|
|
);
|
|
}
|