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,
ScrollView,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
@ -173,62 +174,59 @@ export default function MailScreen() {
</View>
)}
{/* Section header with prominent + button */}
<View
style={{
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' }}
{/* Section header with prominent + button — hidden in empty state (CTA lives there) */}
{accounts.length > 0 && (
<View
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',
alignItems: 'center',
marginBottom: 10,
paddingHorizontal: 2,
}}
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={{
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',
alignItems: 'center',
paddingHorizontal: 14,
@ -250,9 +248,9 @@ export default function MailScreen() {
>
{t('mail.add_account')}
</Text>
</View>
</Pressable>
</View>
</TouchableOpacity>
</View>
)}
{/* Account cards or empty */}
{accounts.length === 0 ? (

View File

@ -27,6 +27,13 @@ export type SheetField = {
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;
@ -41,7 +48,7 @@ type Props = {
*
* 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 [activeIndex, setActiveIndex] = useState(0);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
@ -84,142 +91,157 @@ export function SheetFieldStack({ fields, children, onComplete }: Props) {
const isLast = activeIndex === fields.length - 1;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 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 }}>
{/* Intro: immer sichtbar, schrumpft bei wenig Platz (Tastatur offen) */}
{intro != null && (
<ScrollView
style={{ flexShrink: 1 }}
contentContainerStyle={{ padding: 16, paddingBottom: 0 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<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>
))}
{intro}
</ScrollView>
)}
{/* Aktives Feld */}
{!allDone && (
<View>
<Text
{/* 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={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
marginBottom: 6,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
}}
>
{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 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>
))}
<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] && (
{/* Aktives Feld */}
{!allDone && (
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginTop: 4,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
marginBottom: 6,
}}
>
{fieldErrors[fields[activeIndex].key]}
{fields[activeIndex].label}
</Text>
)}
</View>
)}
<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>
{/* Rest des Formulars — sichtbar wenn alle Felder durch */}
{allDone && children}
</ScrollView>
<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>
);
}

View File

@ -189,92 +189,93 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
),
},
]}
onComplete={() => setFieldsComplete(true)}
>
{/* App-Password-Guide — über den Datenschutz-Hinweis */}
{selectedProvider && selectedProvider.id !== 'other' && (
<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
intro={
<View style={{ gap: 10 }}>
{/* App-Password-Guide — provider-spezifisch, nicht für 'other' */}
{selectedProvider && selectedProvider.id !== 'other' && (
<View
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
{t(selectedProvider.guideKey)}
</Text>
{selectedProvider.guideUrl.length > 0 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
>
<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={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
}}
>
{t('mail.app_password_open_link')}
{t(selectedProvider.guideKey)}
</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>
)}
{/* 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>
}
onComplete={() => setFieldsComplete(true)}
>
{/* Fehler */}
{(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
<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 { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
@ -81,12 +81,10 @@ export function MailEmptyState({ onConnectPress }: Props) {
</View>
{/* CTA */}
<Pressable
<TouchableOpacity
onPress={onConnectPress}
style={({ pressed }) => ({
opacity: pressed ? 0.85 : 1,
alignSelf: 'stretch',
})}
activeOpacity={0.85}
style={{ alignSelf: 'stretch' }}
>
<View style={{
backgroundColor: '#007AFF',
@ -99,7 +97,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
{t('mail.empty_state_cta')}
</Text>
</View>
</Pressable>
</TouchableOpacity>
</View>
);
}