fix(native/mail): duplicate add-button in empty state + intro hints in ConnectMailSheet

- 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>
This commit is contained in:
chahinebrini 2026-05-12 23:39:22 +02:00
parent 3eaf3f098a
commit a3f892ddac
4 changed files with 280 additions and 261 deletions

View File

@ -5,6 +5,7 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
@ -173,62 +174,59 @@ export default function MailScreen() {
</View> </View>
)} )}
{/* Section header with prominent + button */} {/* Section header with prominent + button — hidden in empty state (CTA lives there) */}
<View {accounts.length > 0 && (
style={{ <View
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
paddingHorizontal: 2,
}}
>
<View style={{ flex: 1, marginRight: 10 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
>
{t('mail.section_accounts')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{maxAccounts === Infinity
? t('mail.section_accounts_count_unlimited', { used: accounts.length })
: t('mail.section_accounts_count', {
used: accounts.length,
max: maxAccounts,
})}
</Text>
</View>
<Pressable
onPress={handleAddPress}
disabled={limitReached}
android_ripple={{ color: '#0066cc' }}
style={{ style={{
backgroundColor: limitReached ? colors.surfaceElevated : '#007AFF', flexDirection: 'row',
borderRadius: 12, alignItems: 'center',
opacity: limitReached ? 0.7 : 1, marginBottom: 10,
shadowColor: '#007AFF', paddingHorizontal: 2,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: limitReached ? 0 : 0.25,
shadowRadius: 8,
elevation: limitReached ? 0 : 4,
}} }}
accessibilityLabel={t('mail.add_account_a11y')}
> >
<View <View style={{ flex: 1, marginRight: 10 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
>
{t('mail.section_accounts')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{maxAccounts === Infinity
? t('mail.section_accounts_count_unlimited', { used: accounts.length })
: t('mail.section_accounts_count', {
used: accounts.length,
max: maxAccounts,
})}
</Text>
</View>
<TouchableOpacity
onPress={handleAddPress}
disabled={limitReached}
activeOpacity={limitReached ? 1 : 0.8}
accessibilityLabel={t('mail.add_account_a11y')}
style={{ style={{
backgroundColor: limitReached ? colors.surfaceElevated : '#007AFF',
borderRadius: 12,
opacity: limitReached ? 0.7 : 1,
shadowColor: '#007AFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: limitReached ? 0 : 0.25,
shadowRadius: 8,
elevation: limitReached ? 0 : 4,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 14,
@ -250,9 +248,9 @@ export default function MailScreen() {
> >
{t('mail.add_account')} {t('mail.add_account')}
</Text> </Text>
</View> </TouchableOpacity>
</Pressable> </View>
</View> )}
{/* Account cards or empty */} {/* Account cards or empty */}
{accounts.length === 0 ? ( {accounts.length === 0 ? (

View File

@ -27,6 +27,13 @@ export type SheetField = {
type Props = { type Props = {
fields: SheetField[]; 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. */ /** Rendert sich nach dem letzten Feld — sichtbar sobald alle Felder ausgefüllt sind. */
children?: ReactNode; children?: ReactNode;
onComplete?: () => void; onComplete?: () => void;
@ -41,7 +48,7 @@ type Props = {
* *
* Wird als `children` von `<FormSheet>` benutzt. * Wird als `children` von `<FormSheet>` benutzt.
*/ */
export function SheetFieldStack({ fields, children, onComplete }: Props) { export function SheetFieldStack({ fields, intro, children, onComplete }: Props) {
const colors = useColors(); const colors = useColors();
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
@ -84,142 +91,157 @@ export function SheetFieldStack({ fields, children, onComplete }: Props) {
const isLast = activeIndex === fields.length - 1; const isLast = activeIndex === fields.length - 1;
return ( return (
<ScrollView <View style={{ flex: 1 }}>
style={{ flex: 1 }} {/* Intro: immer sichtbar, schrumpft bei wenig Platz (Tastatur offen) */}
contentContainerStyle={{ padding: 16, gap: 10 }} {intro != null && (
keyboardShouldPersistTaps="handled" <ScrollView
showsVerticalScrollIndicator={false} style={{ flexShrink: 1 }}
> contentContainerStyle={{ padding: 16, paddingBottom: 0 }}
{/* Abgeschlossene Felder als Chips */} keyboardShouldPersistTaps="handled"
{fields.slice(0, activeIndex).map((field, index) => ( showsVerticalScrollIndicator={false}
<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 }}> {intro}
<Text </ScrollView>
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 */} {/* Chips + aktives Feld + Post-Completion-Inhalt */}
{!allDone && ( <ScrollView
<View> style={{ flex: 1 }}
<Text 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={{ style={{
fontSize: 12, flexDirection: 'row',
fontFamily: 'Nunito_600SemiBold', alignItems: 'center',
color: colors.textMuted, backgroundColor: colors.surface,
marginBottom: 6, borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
}} }}
> >
{fields[activeIndex].label} <View style={{ flex: 1 }}>
</Text> <Text
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}
<View >
style={{ {field.label}
flex: 1, </Text>
flexDirection: 'row', <Text
alignItems: 'center', style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.text, marginTop: 1 }}
backgroundColor: colors.surfaceElevated, numberOfLines={1}
borderRadius: 12, >
paddingHorizontal: 14, {field.secureTextEntry ? '••••••••' : field.value}
borderWidth: fieldErrors[fields[activeIndex].key] ? 1 : 0, </Text>
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> </View>
<Ionicons name="pencil-outline" size={14} color={colors.textMuted} />
</TouchableOpacity>
))}
<TouchableOpacity {/* Aktives Feld */}
activeOpacity={0.8} {!allDone && (
onPress={advanceOrFinish} <View>
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 <Text
style={{ style={{
fontSize: 12, fontSize: 12,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_600SemiBold',
color: '#dc2626', color: colors.textMuted,
marginTop: 4, marginBottom: 6,
}} }}
> >
{fieldErrors[fields[activeIndex].key]} {fields[activeIndex].label}
</Text> </Text>
)} <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
</View> <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>
{/* Rest des Formulars — sichtbar wenn alle Felder durch */} <TouchableOpacity
{allDone && children} activeOpacity={0.8}
</ScrollView> 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>
); );
} }

View File

@ -189,92 +189,93 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
), ),
}, },
]} ]}
onComplete={() => setFieldsComplete(true)} intro={
> <View style={{ gap: 10 }}>
{/* App-Password-Guide — über den Datenschutz-Hinweis */} {/* App-Password-Guide — provider-spezifisch, nicht für 'other' */}
{selectedProvider && selectedProvider.id !== 'other' && ( {selectedProvider && selectedProvider.id !== 'other' && (
<View <View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
marginBottom: 10,
}}
>
<Ionicons
name="information-circle"
size={18}
color="#1d4ed8"
style={{ marginTop: 1 }}
/>
<View style={{ flex: 1, gap: 4 }}>
<Text
style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}
>
{t('mail.app_password_required_title')}
</Text>
<Text
style={{ style={{
fontSize: 12, flexDirection: 'row',
fontFamily: 'Nunito_400Regular', gap: 10,
color: '#1d4ed8', padding: 12,
lineHeight: 17, backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}} }}
> >
{t(selectedProvider.guideKey)} <Ionicons
</Text> name="information-circle"
{selectedProvider.guideUrl.length > 0 && ( size={18}
<TouchableOpacity color="#1d4ed8"
activeOpacity={0.7} style={{ marginTop: 1 }}
onPress={() => Linking.openURL(selectedProvider.guideUrl)} />
> <View style={{ flex: 1, gap: 4 }}>
<Text
style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}
>
{t('mail.app_password_required_title')}
</Text>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 12,
fontFamily: 'Nunito_600SemiBold', fontFamily: 'Nunito_400Regular',
color: '#007AFF', color: '#1d4ed8',
marginTop: 2, lineHeight: 17,
}} }}
> >
{t('mail.app_password_open_link')} {t(selectedProvider.guideKey)}
</Text> </Text>
</TouchableOpacity> {selectedProvider.guideUrl.length > 0 && (
)} <TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
{/* Datenschutz-Zusicherung — immer sichtbar */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
}}
>
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#166534',
lineHeight: 17,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View> </View>
</View> </View>
)} }
onComplete={() => setFieldsComplete(true)}
{/* Datenschutz-Hinweis */} >
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
marginBottom: 10,
}}
>
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#166534',
lineHeight: 17,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View>
{/* Fehler */} {/* Fehler */}
{(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && ( {(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
<Text <Text

View File

@ -1,4 +1,4 @@
import { Pressable, Text, View } from 'react-native'; import { Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
@ -81,12 +81,10 @@ export function MailEmptyState({ onConnectPress }: Props) {
</View> </View>
{/* CTA */} {/* CTA */}
<Pressable <TouchableOpacity
onPress={onConnectPress} onPress={onConnectPress}
style={({ pressed }) => ({ activeOpacity={0.85}
opacity: pressed ? 0.85 : 1, style={{ alignSelf: 'stretch' }}
alignSelf: 'stretch',
})}
> >
<View style={{ <View style={{
backgroundColor: '#007AFF', backgroundColor: '#007AFF',
@ -99,7 +97,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
{t('mail.empty_state_cta')} {t('mail.empty_state_cta')}
</Text> </Text>
</View> </View>
</Pressable> </TouchableOpacity>
</View> </View>
); );
} }