frontend/settings: confirm logout in a custom surface
Logout asked for confirmation through the system alert, which can't be styled or given test ids. It now opens a reusable in-app confirmation card with a destructive action, adopted first here so the data export can reuse it, and the e2e flow matches it by id instead of the alert's copy
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago
254c7850308a7fd9fe9233bdd97657ada3d3eab8 Browse filesParents
bc71b93Changed files
4
Additions
+180
Deletions
-16
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
SectionList,
5 5
View,
16 16
import { useThemeColor } from "@/hooks/use-theme-color";
17 17
import { useAuth } from "@/context/AuthContext";
18 18
import { Section, SectionHeader } from "@/components/ui/Sections";
19 19
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
20 20
import { Button } from "@/components/ui/Button";
21 +
import { ConfirmModal } from "@/components/ui/ConfirmModal";
21 22
import { TimePicker } from "@/components/ui/TimePicker";
22 23
import { usePatchUserMe } from "@/hooks/useUserPreferences.queries";
23 24
import { useToast } from '@/context/ToastContext';
24 25
25 26
export default function Settings() {
26 27
const { user, logout, updateAuthUser } = useAuth();
27 28
const [allowWeeklyInsights, setAllowWeeklyInsights] = useState(false);
29 +
const [logoutVisible, setLogoutVisible] = useState(false);
28 30
const { showToast } = useToast();
29 31
30 32
const notificationsEnabled = user?.notificationsEnabled ?? true;
31 33
const dailyReminderTime = user?.dailyReminderTime ?? "20:00";
32 34
43 45
patchUserMe.mutate({ dailyReminderTime: time });
44 46
};
45 47
46 48
const textColor = useThemeColor({}, "text");
47 49
48 -
const handleLogout = async () => {
49 -
Alert.alert("Sair", "Tem certeza que quer sair?", [
50 -
{ text: "Cancelar", style: "cancel" },
51 -
{
52 -
text: "Sair",
53 -
style: "destructive",
54 -
onPress: logout,
55 -
},
56 -
]);
50 +
const handleLogout = () => {
51 +
setLogoutVisible(true);
57 52
};
58 53
59 54
const handleProfilePress = () => {
60 55
router.push("/profile");
61 56
};
318 313
onPress={handleLogout}
319 314
variant="outline"
320 315
style={{ borderColor: Colors.light.danger }}
321 316
testID="button-logout"
322 317
textStyle={{ color: Colors.light.danger }}
318 +
/>
319 +
320 +
<ConfirmModal
321 +
visible={logoutVisible}
322 +
title="Sair da conta"
323 +
message="Tem certeza que quer sair?"
324 +
confirmLabel="Sair"
325 +
cancelLabel="Cancelar"
326 +
destructive
327 +
onCancel={() => setLogoutVisible(false)}
328 +
onConfirm={() => {
329 +
setLogoutVisible(false);
330 +
logout();
331 +
}}
332 +
testID="logout-confirm"
323 333
/>
324 334
</ScreenLayout>
325 335
);
326 336
}
327 337
360 370
height: StyleSheet.hairlineWidth,
361 371
backgroundColor: "#D1D5DB",
362 372
marginVertical: 16,
363 373
},
364 374
});
/dev/null → frontend/components/ui/ConfirmModal.tsx
1 +
import React from 'react';
2 +
import { Modal, Pressable, StyleSheet, View } from 'react-native';
3 +
4 +
import { Button } from '@/components/ui/Button';
5 +
import { Text } from '@/components/ui/Text';
6 +
import {
7 +
BorderRadius,
8 +
Colors,
9 +
Spacing,
10 +
Shadows,
11 +
Typography,
12 +
} from '@/constants/theme';
13 +
14 +
interface ConfirmModalProps {
15 +
visible: boolean;
16 +
title: string;
17 +
message?: string;
18 +
detail?: string;
19 +
confirmLabel?: string;
20 +
cancelLabel?: string;
21 +
destructive?: boolean;
22 +
loading?: boolean;
23 +
onConfirm: () => void;
24 +
onCancel: () => void;
25 +
testID?: string;
26 +
}
27 +
28 +
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
29 +
visible,
30 +
title,
31 +
message,
32 +
detail,
33 +
confirmLabel = 'Confirmar',
34 +
cancelLabel = 'Cancelar',
35 +
destructive = false,
36 +
loading = false,
37 +
onConfirm,
38 +
onCancel,
39 +
testID = 'confirm-modal',
40 +
}) => {
41 +
const dismiss = () => {
42 +
if (!loading) {
43 +
onCancel();
44 +
}
45 +
};
46 +
47 +
return (
48 +
<Modal
49 +
visible={visible}
50 +
transparent
51 +
animationType="fade"
52 +
statusBarTranslucent
53 +
onRequestClose={dismiss}
54 +
>
55 +
<View style={styles.overlay}>
56 +
<Pressable
57 +
style={StyleSheet.absoluteFill}
58 +
onPress={dismiss}
59 +
testID={`${testID}-overlay`}
60 +
/>
61 +
62 +
<View style={styles.cardContainer} pointerEvents="box-none">
63 +
<Pressable
64 +
style={styles.card}
65 +
accessibilityViewIsModal
66 +
testID={testID}
67 +
>
68 +
<Text style={styles.title}>{title}</Text>
69 +
70 +
{message ? <Text style={styles.message}>{message}</Text> : null}
71 +
72 +
{detail ? (
73 +
<View style={styles.detail}>
74 +
<Text style={styles.detailText}>{detail}</Text>
75 +
</View>
76 +
) : null}
77 +
78 +
<View style={styles.footer}>
79 +
<Button
80 +
title={cancelLabel}
81 +
variant="ghost"
82 +
style={styles.footerButton}
83 +
disabled={loading}
84 +
onPress={onCancel}
85 +
testID={`${testID}-cancel`}
86 +
/>
87 +
<Button
88 +
title={confirmLabel}
89 +
variant={destructive ? 'danger' : 'primary'}
90 +
style={styles.footerButton}
91 +
loading={loading}
92 +
onPress={onConfirm}
93 +
testID={`${testID}-confirm`}
94 +
/>
95 +
</View>
96 +
</Pressable>
97 +
</View>
98 +
</View>
99 +
</Modal>
100 +
);
101 +
};
102 +
103 +
const OVERLAY_BACKGROUND = 'rgba(15, 23, 42, 0.55)';
104 +
105 +
const styles = StyleSheet.create({
106 +
overlay: {
107 +
flex: 1,
108 +
backgroundColor: OVERLAY_BACKGROUND,
109 +
},
110 +
cardContainer: {
111 +
flex: 1,
112 +
width: '100%',
113 +
alignItems: 'center',
114 +
justifyContent: 'center',
115 +
padding: Spacing.containerPadding * 1.5,
116 +
},
117 +
card: {
118 +
width: '100%',
119 +
maxWidth: 400,
120 +
backgroundColor: Colors.light.surface,
121 +
borderRadius: BorderRadius.xl,
122 +
borderWidth: 1,
123 +
borderColor: Colors.light.cardBorder,
124 +
padding: Spacing.containerPadding + 4,
125 +
...Shadows.md,
126 +
},
127 +
title: {
128 +
...Typography.headlineMd,
129 +
color: Colors.light.text,
130 +
textAlign: 'center',
131 +
},
132 +
message: {
133 +
...Typography.bodyMd,
134 +
color: Colors.light.textSecondary,
135 +
textAlign: 'center',
136 +
marginTop: Spacing.inlineGapSm,
137 +
},
138 +
detail: {
139 +
marginTop: Spacing.cardGap,
140 +
backgroundColor: Colors.light.accentBlue,
141 +
borderRadius: BorderRadius.md,
142 +
paddingVertical: Spacing.inlineGapSm,
143 +
paddingHorizontal: Spacing.cardGap,
144 +
},
145 +
detailText: {
146 +
...Typography.bodyLg,
147 +
color: Colors.light.text,
148 +
textAlign: 'center',
149 +
},
150 +
footer: {
151 +
flexDirection: 'row',
152 +
gap: Spacing.cardGap,
153 +
marginTop: Spacing.containerPadding,
154 +
},
155 +
footerButton: {
156 +
flex: 1,
157 +
},
158 +
});
frontend/e2e/README.md
1 1
# Mobile E2E (Maestro on a physical Android device)
2 2
3 3
Smoke coverage for the core journey: **login → register a mood → logout**. Runs
4 4
on a real Android device over `adb` (no emulator), against a dev build of the
5 5
app talking to the normal local API
96 96
OS or to dev tooling. These are deliberate, and the only ones a flow may assert
97 97
on:
98 98
99 99
| Where | Selector | Why |
100 100
| --- | --- | --- |
101 -
| Logout confirm `Alert` | `^Sair$` with `index: 1` | RN native dialog, no ids, will be fixed eventually |
102 101
| History card annotation | `"e2e smoke register"` | the one *data* assertion; proves write-through |
103 102
| Notification permission | `com.android.permissioncontroller:id/permission_allow_button` | AOSP system dialog |
104 103
| expo-dev-client onboarding | `^Continue$` | dev tooling, not app UI |
105 104
| expo-dev-client dev menu | `^Close$` (content-desc) | dev tooling, not app UI |
106 105
110 109
111 110
- **The formSheet opens at its half detent.** `entry/mood-components` uses
112 111
`sheetInitialDetentIndex: 1` of `[0.25, 0.5, 1]`, so only the first rows of
113 112
the component list are mounted
114 113
- The soft keyboard covers `button-login`; the flow calls `hideKeyboard` first
frontend/e2e/logout.yaml
1 1
appId: com.eletrotupi.orbit
2 2
name: Smoke — Logout
3 3
tags:
4 4
- smoke
5 5
---
11 11
element:
12 12
id: button-logout
13 13
direction: DOWN
14 14
- tapOn:
15 15
id: button-logout
16 -
# RN Alert renders a native system dialog, the only screen that can't expose
17 -
# testIDs. These two text matches are a deliberate, unavoidable exception
18 -
# TODO: Fix that to use some other, more custom (and pretty) confirm dialog
19 -
- assertVisible: "Tem certeza que quer sair?"
16 +
- assertVisible:
17 +
id: logout-confirm
20 18
- tapOn:
21 -
text: "^Sair$"
22 -
index: 1
19 +
id: logout-confirm-confirm
23 20
- extendedWaitUntil:
24 21
visible:
25 22
id: text-welcome
26 23
timeout: 15000