eletrotupi / tcc/ commit / 254c785

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 files
Parents bc71b93
Changed 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