eletrotupi / tcc/ commit / 6bdc28a

frontend/auth: give the auth screens one layout

All five screens carried their own copy of the same keyboard avoiding
scroll view, which is why the form re-centred and scrolled as soon as an
error appeared. One shared screen pins content to the top, and login
keeps the only animated logo
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago 6bdc28a3adcc45cf37288df8777dfa536ce0f07a Browse files
Parents 1144332
Changed files
6
Additions
+458
Deletions
-564
frontend/app/auth/activate.tsx
1 1
import React, { useEffect, useRef, useState } from 'react';
2 -
import {
3 -
  View,
4 -
  StyleSheet,
5 -
  ScrollView,
6 -
  KeyboardAvoidingView,
7 -
} from 'react-native';
8 -
import { Text } from '@/components/ui/Text';
2 +
import { Pressable, StyleSheet, View } from 'react-native';
9 3
import { router } from 'expo-router';
10 -
import { SafeAreaView } from 'react-native-safe-area-context';
11 4
import { useThemeColor } from '@/hooks/use-theme-color';
12 5
import { Button, CodeInput } from '@/components/ui';
13 6
import type { CodeInputHandle } from '@/components/ui/CodeInput';
7 +
import { Text } from '@/components/ui/Text';
8 +
import { AuthScreen } from '@/components/auth/AuthScreen';
14 9
import { Colors } from '@/constants/theme';
15 10
import { useAuth } from '@/context/AuthContext';
16 11
import { useToast } from '@/context/ToastContext';
17 12
import { translateError } from '@/lib/errors/translations';
18 13

24 19
  const [codeError, setCodeError] = useState<string>();
25 20
  const [loading, setLoading] = useState(false);
26 21
  const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
27 22
  const codeInputRef = useRef<CodeInputHandle>(null);
28 23

29 -
  const { activate, requestActivateCode } = useAuth();
24 +
  const { activate, requestActivateCode, logout } = useAuth();
30 25
  const { showToast } = useToast();
31 26
  const textColor = useThemeColor({}, 'text');
32 -
  const backgroundColor = useThemeColor({}, 'background');
27 +
  const tintColor = useThemeColor({}, 'tint');
33 28

34 29
  useEffect(() => {
35 30
    if (secondsLeft <= 0) return;
36 31

37 32
    const timer = setTimeout(() => setSecondsLeft((value) => value - 1), 1000);
67 62
  const handleCodeChange = (value: string) => {
68 63
    setCode(value);
69 64
    setCodeError(undefined);
70 65
  };
71 66

67 +
  // Abandons this half-created session. Nothing exists on an inactive account
68 +
  // yet, so there is nothing to warn about — it just clears the local session
69 +
  // and drops back to login.
70 +
  const backToLogin = async () => {
71 +
    await logout();
72 +
    router.replace('/auth/login');
73 +
  };
74 +

72 75
  const resendCode = async () => {
73 76
    if (secondsLeft > 0) return;
74 77

75 78
    setSecondsLeft(RESEND_COOLDOWN_SECONDS);
76 79
    try {
81 84
      showToast('Falha ao reenviar o código. Tente novamente.', 'error');
82 85
    }
83 86
  };
84 87

85 88
  return (
86 -
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
87 -
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
88 -
        <ScrollView
89 -
          contentContainerStyle={styles.scrollContainer}
90 -
          showsVerticalScrollIndicator={false}
91 -
        >
92 -
          <View style={styles.header}>
93 -
            <Text style={[styles.title, { color: textColor }]}>Ativar a conta</Text>
94 -
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
95 -
              Digite o código de 6 dígitos enviado para seu email
96 -
            </Text>
97 -
          </View>
89 +
    <AuthScreen
90 +
      title="Ativar a conta"
91 +
      subtitle="Digite o código de 6 dígitos enviado para seu email"
92 +
      footer={
93 +
        <View style={styles.footer}>
94 +
          <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
95 +
            Não recebeu?{' '}
96 +
          </Text>
98 97

99 -
          <View style={styles.form}>
100 -
            <CodeInput
101 -
              ref={codeInputRef}
102 -
              value={code}
103 -
              onChange={handleCodeChange}
104 -
              onComplete={submitCode}
105 -
              length={CODE_LENGTH}
106 -
              error={codeError}
107 -
              editable={!loading}
108 -
              accessibilityLabel="Código de ativação de 6 dígitos"
109 -
              testIdPrefix="activate-code"
110 -
            />
98 +
          <Button
99 +
            variant="outline"
100 +
            title={
101 +
              secondsLeft > 0
102 +
                ? `Reenviar em ${secondsLeft}s`
103 +
                : 'Reenviar um novo código'
104 +
            }
105 +
            onPress={resendCode}
106 +
            disabled={secondsLeft > 0}
107 +
          />
111 108

112 -
            <Button
113 -
              title="Ativar conta"
114 -
              onPress={() => submitCode(code)}
115 -
              loading={loading}
116 -
              style={styles.activateButton}
117 -
            />
118 -
          </View>
119 -

120 -
          <View style={styles.footer}>
121 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
122 -
              Não recebeu?{' '}
109 +
          <Pressable
110 +
            onPress={backToLogin}
111 +
            style={styles.backLink}
112 +
            testID="button-back-to-login"
113 +
          >
114 +
            <Text style={[styles.backLinkText, { color: tintColor }]}>
115 +
              Voltar para login
123 116
            </Text>
117 +
          </Pressable>
118 +
        </View>
119 +
      }
120 +
    >
121 +
      <View style={styles.form}>
122 +
        <CodeInput
123 +
          ref={codeInputRef}
124 +
          value={code}
125 +
          onChange={handleCodeChange}
126 +
          onComplete={submitCode}
127 +
          length={CODE_LENGTH}
128 +
          error={codeError}
129 +
          editable={!loading}
130 +
          accessibilityLabel="Código de ativação de 6 dígitos"
131 +
          testIdPrefix="activate-code"
132 +
        />
124 133

125 -
            <Button
126 -
              variant="outline"
127 -
              title={
128 -
                secondsLeft > 0
129 -
                  ? `Reenviar em ${secondsLeft}s`
130 -
                  : 'Reenviar um novo código'
131 -
              }
132 -
              onPress={resendCode}
133 -
              disabled={secondsLeft > 0}
134 -
            />
135 -
          </View>
136 -
        </ScrollView>
137 -
      </KeyboardAvoidingView>
138 -
    </SafeAreaView>
134 +
        <Button
135 +
          title="Ativar conta"
136 +
          onPress={() => submitCode(code)}
137 +
          loading={loading}
138 +
          style={styles.activateButton}
139 +
        />
140 +
      </View>
141 +
    </AuthScreen>
139 142
  );
140 143
}
141 144

142 145
const styles = StyleSheet.create({
143 -
  container: {
144 -
    flex: 1,
145 -
  },
146 -
  keyboardView: {
147 -
    flex: 1,
148 -
  },
149 -
  scrollContainer: {
150 -
    flexGrow: 1,
151 -
    paddingHorizontal: 24,
152 -
    justifyContent: 'center',
153 -
    minHeight: '100%',
154 -
  },
155 -
  header: {
156 -
    alignItems: 'center',
157 -
    marginBottom: 40,
158 -
  },
159 -
  title: {
160 -
    fontSize: 28,
161 -
    fontWeight: 'bold',
162 -
    marginBottom: 8,
163 -
  },
164 -
  subtitle: {
165 -
    fontSize: 16,
166 -
    textAlign: 'center',
167 -
  },
168 146
  form: {
169 147
    marginBottom: 32,
170 148
  },
171 149
  activateButton: {
172 150
    marginTop: 8,
173 151
  },
174 152
  footer: {
175 -
    marginTop: 20,
176 153
    borderTopColor: Colors.light.accentBlue,
177 154
    borderTopWidth: 1,
178 155
    paddingTop: 20,
179 156
  },
180 157
  footerText: {
181 158
    textAlign: 'center',
182 159
    marginBottom: 10,
160 +
  },
161 +
  backLink: {
162 +
    marginTop: 20,
163 +
    alignItems: 'center',
164 +
  },
165 +
  backLinkText: {
166 +
    fontSize: 14,
167 +
    fontWeight: '500',
183 168
  },
184 169
});
frontend/app/auth/forgot-password.tsx
1 1
import React, { useState } from 'react';
2 -
import {
3 -
  View,
4 -
  StyleSheet,
5 -
  ScrollView,
6 -
  KeyboardAvoidingView,
7 -
} from 'react-native';
8 -
import { Text } from '@/components/ui/Text';
9 -
import { Link, router } from 'expo-router';
10 -
import { SafeAreaView } from 'react-native-safe-area-context';
2 +
import { StyleSheet, View } from 'react-native';
3 +
import { Link } from 'expo-router';
11 4
import { useThemeColor } from '@/hooks/use-theme-color';
12 5
import { Button, Input } from '@/components/ui';
6 +
import { Text } from '@/components/ui/Text';
7 +
import { AuthScreen } from '@/components/auth/AuthScreen';
13 8
import { useAuth } from '@/context/AuthContext';
14 9
import { useToast } from '@/context/ToastContext';
15 10
import { translateError } from '@/lib/errors/translations';
16 11

17 12
export default function ForgotPasswordScreen() {
18 13
  const [email, setEmail] = useState('');
19 14
  const [emailSent, setEmailSent] = useState(false);
20 -
  const [resetToken, setResetToken] = useState(''); // Only for development
21 15
  const [errors, setErrors] = useState<{ email?: string }>({});
22 16

23 17
  const { forgotPassword } = useAuth();
24 18
  const { showToast } = useToast();
25 19
  const [loading, setLoading] = useState(false);
26 20
  const textColor = useThemeColor({}, 'text');
27 -
  const backgroundColor = useThemeColor({}, 'background');
28 21
  const tintColor = useThemeColor({}, 'tint');
29 22

30 23
  const validateForm = () => {
31 24
    const newErrors: { email?: string } = {};
32 25

43 36
  const handleForgotPassword = async () => {
44 37
    if (!validateForm()) return;
45 38

46 39
    setLoading(true);
47 40
    try {
48 -
      const data = await forgotPassword(email);
41 +
      await forgotPassword(email);
49 42
      setEmailSent(true);
50 -
      // In development, show the reset token
51 -
      if (data.token) {
52 -
        setResetToken(data.token);
53 -
      }
54 43
    } catch (error) {
55 44
      showToast(translateError(error) || 'Falha ao enviar link de recuperação', 'error');
56 45
    } finally {
57 46
      setLoading(false);
58 47
    }
59 48
  };
60 49

61 -
  const handleUseToken = () => {
62 -
    if (resetToken) {
63 -
      router.push({
64 -
        pathname: '/auth/reset-password',
65 -
        params: { token: resetToken },
66 -
      });
67 -
    }
68 -
  };
69 -

70 50
  if (emailSent) {
71 51
    return (
72 -
      <SafeAreaView style={[styles.container, { backgroundColor }]}>
73 -
        <View style={styles.successContainer}>
74 -
          <Text style={[styles.title, { color: textColor }]}>Verifique seu email</Text>
75 -
          <Text style={[styles.message, { color: textColor, opacity: 0.7 }]}>
76 -
            Enviamos as instruções para resetar sua senha para {email}
77 -
          </Text>
78 -

79 -
          {/* Development only - show reset token */}
80 -
          {resetToken && (
81 -
            <View style={styles.devSection}>
82 -
              <Text style={[styles.devTitle, { color: tintColor }]}>
83 -
                Dev mode:
84 -
              </Text>
85 -
              <Text style={[styles.devText, { color: textColor, opacity: 0.7 }]}>
86 -
                Token: {resetToken}
87 -
              </Text>
88 -
              <Button
89 -
                title="Usar token para resetar senha"
90 -
                onPress={handleUseToken}
91 -
                style={styles.devButton}
92 -
              />
93 -
            </View>
94 -
          )}
95 -

52 +
      <AuthScreen
53 +
        title="Verifique seu email"
54 +
        subtitle={`Enviamos as instruções para resetar sua senha para ${email}`}
55 +
        footer={
96 56
          <View style={styles.actions}>
97 57
            <Button
98 58
              title="Reenviar email"
99 59
              variant="outline"
100 60
              onPress={() => setEmailSent(false)}
101 -
              style={styles.resendButton}
102 61
            />
103 62

104 63
            <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
105 64
              <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
106 65
                Voltar para login
107 66
              </Text>
108 67
            </Link>
109 68
          </View>
110 -
        </View>
111 -
      </SafeAreaView>
69 +
        }
70 +
      />
112 71
    );
113 72
  }
114 73

115 74
  return (
116 -
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
117 -
      <KeyboardAvoidingView
118 -
        behavior='height'
119 -
        style={styles.keyboardView}
120 -
      >
121 -
        <ScrollView
122 -
          contentContainerStyle={styles.scrollContainer}
123 -
          showsVerticalScrollIndicator={false}
124 -
        >
125 -
          <View style={styles.header}>
126 -
            <Text style={[styles.title, { color: textColor }]}>Esqueceu a senha?</Text>
127 -
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
128 -
              Insira seu email para receber as instruções de recuperação
75 +
    <AuthScreen
76 +
      title="Esqueceu a senha?"
77 +
      subtitle="Insira seu email para receber as instruções de recuperação"
78 +
      footer={
79 +
        <View style={styles.footer}>
80 +
          <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
81 +
            Lembra tua senha?{' '}
82 +
          </Text>
83 +
          <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
84 +
            <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
85 +
              Faça login
129 86
            </Text>
130 -
          </View>
131 -

132 -
          <View style={styles.form}>
133 -
            <Input
134 -
              label="Email"
135 -
              type="email"
136 -
              value={email}
137 -
              onChangeText={setEmail}
138 -
              placeholder="Qual seu email?"
139 -
              error={errors.email}
140 -
              autoCapitalize="none"
141 -
            />
142 -

143 -
            <Button
144 -
              title="Enviar link de recuperação"
145 -
              onPress={handleForgotPassword}
146 -
              loading={loading}
147 -
              style={styles.resetButton}
148 -
            />
149 -
          </View>
87 +
          </Link>
88 +
        </View>
89 +
      }
90 +
    >
91 +
      <View style={styles.form}>
92 +
        <Input
93 +
          label="Email"
94 +
          type="email"
95 +
          value={email}
96 +
          onChangeText={setEmail}
97 +
          placeholder="Qual seu email?"
98 +
          error={errors.email}
99 +
          autoCapitalize="none"
100 +
        />
150 101

151 -
          <View style={styles.footer}>
152 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
153 -
              Lembra tua senha?{' '}
154 -
            </Text>
155 -
            <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
156 -
              <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
157 -
                Faça login
158 -
              </Text>
159 -
            </Link>
160 -
          </View>
161 -
        </ScrollView>
162 -
      </KeyboardAvoidingView>
163 -
    </SafeAreaView>
102 +
        <Button
103 +
          title="Enviar link de recuperação"
104 +
          onPress={handleForgotPassword}
105 +
          loading={loading}
106 +
          style={styles.resetButton}
107 +
        />
108 +
      </View>
109 +
    </AuthScreen>
164 110
  );
165 111
}
166 112

167 113
const styles = StyleSheet.create({
168 -
  container: {
169 -
    flex: 1,
170 -
  },
171 -
  keyboardView: {
172 -
    flex: 1,
173 -
  },
174 -
  scrollContainer: {
175 -
    flexGrow: 1,
176 -
    paddingHorizontal: 24,
177 -
    justifyContent: 'center',
178 -
    minHeight: '100%',
179 -
  },
180 -
  successContainer: {
181 -
    flex: 1,
182 -
    justifyContent: 'center',
183 -
    paddingHorizontal: 24,
184 -
  },
185 -
  header: {
186 -
    alignItems: 'center',
187 -
    marginBottom: 40,
188 -
  },
189 -
  title: {
190 -
    fontSize: 28,
191 -
    fontWeight: 'bold',
192 -
    marginBottom: 8,
193 -
    textAlign: 'center',
194 -
  },
195 -
  subtitle: {
196 -
    fontSize: 16,
197 -
    textAlign: 'center',
198 -
    lineHeight: 24,
199 -
  },
200 -
  message: {
201 -
    fontSize: 16,
202 -
    textAlign: 'center',
203 -
    lineHeight: 24,
204 -
    marginBottom: 32,
205 -
  },
206 114
  form: {
207 115
    marginBottom: 32,
208 116
  },
209 117
  resetButton: {
210 118
    marginTop: 8,
211 119
  },
212 120
  actions: {
213 121
    gap: 16,
214 122
  },
215 -
  resendButton: {
216 -
    marginBottom: 8,
217 -
  },
218 -
  devSection: {
219 -
    marginBottom: 32,
220 -
    padding: 16,
221 -
    borderRadius: 8,
222 -
    backgroundColor: 'rgba(74, 144, 226, 0.1)',
223 -
  },
224 -
  devTitle: {
225 -
    fontSize: 14,
226 -
    fontWeight: '600',
227 -
    marginBottom: 8,
228 -
  },
229 -
  devText: {
230 -
    fontSize: 12,
231 -
    marginBottom: 12,
232 -
    fontFamily: 'monospace',
233 -
  },
234 -
  devButton: {
235 -
    marginTop: 8,
236 -
  },
237 123
  link: {
238 124
    textAlign: 'center',
239 125
    fontSize: 14,
240 126
    fontWeight: '500',
241 127
  },
242 128
  footer: {
243 129
    flexDirection: 'row',
244 130
    justifyContent: 'center',
245 131
    alignItems: 'center',
246 -
    marginTop: 'auto',
247 -
    paddingBottom: 20,
248 132
  },
249 133
  footerText: {
250 134
    fontSize: 14,
251 135
  },
252 136
});
frontend/app/auth/login.tsx
1 1
import React, { useState, useEffect } from "react";
2 -
import {
3 -
  View,
4 -
  StyleSheet,
5 -
  ScrollView,
6 -
  KeyboardAvoidingView,
7 -
  Animated,
8 -
  Easing,
9 -
  Image,
10 -
} from "react-native";
11 -
import { Text } from "@/components/ui/Text";
2 +
import { Animated, Easing, Image, StyleSheet, View } from "react-native";
3 +
import { StatusBar } from "expo-status-bar";
12 4
import { Link, router } from "expo-router";
13 -
import { SafeAreaView } from "react-native-safe-area-context";
14 -
import { StatusBar } from "expo-status-bar";
15 5
import { useThemeColor } from "@/hooks/use-theme-color";
16 6
import { Button, Input } from "@/components/ui";
7 +
import { Text } from "@/components/ui/Text";
8 +
import { AuthScreen } from "@/components/auth/AuthScreen";
17 9
import { useAuth } from "@/context/AuthContext";
18 10
import { apiClient, ApiError } from "@/lib/api";
19 11
import { useToast } from "@/context/ToastContext";
20 12
import { translateError, translateFields } from "@/lib/errors/translations";
21 13
import { registerForPushNotifications } from "@/hooks/useRegisterPushToken";
29 21
  const [loading, setLoading] = useState(false);
30 22

31 23
  const { login, updateAuthUser } = useAuth();
32 24
  const { showToast } = useToast();
33 25
  const textColor = useThemeColor({}, "text");
34 -
  const backgroundColor = useThemeColor({}, "background");
35 26
  const tintColor = useThemeColor({}, "tint");
36 27

37 28
  const [spinValue] = useState(() => new Animated.Value(0));
38 29
  useEffect(() => {
39 30
    const animation = Animated.loop(
87 78
          const updateResponse = await apiClient.updateUser({
88 79
            id: response.user.id,
89 80
            pushToken: token,
90 81
          });
91 82

92 -
          console.log("UPDATE USER response: ", updateResponse);
93 -

94 83
          if (updateResponse) {
95 84
            updateAuthUser(updateResponse.user);
96 85
          }
97 86
        }
98 87
      }
99 88

100 89
      router.replace("/");
101 90
    } catch (error) {
102 -
      console.log("LOGIN error: ", error);
103 91
      if (error instanceof ApiError && error.fields) {
104 92
        setErrors(translateFields(error.fields));
105 93
      } else {
106 -
        showToast(translateError(error) || 'Falha ao fazer login', 'error');
94 +
        showToast(translateError(error) || "Falha ao fazer login", "error");
107 95
      }
108 96
    } finally {
109 97
      setLoading(false);
110 98
    }
111 99
  };
112 100

113 101
  return (
114 -
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
115 -
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
116 -
        <StatusBar style={'dark'} />
117 -
        <ScrollView
118 -
          contentContainerStyle={styles.scrollContainer}
119 -
          showsVerticalScrollIndicator={false}
120 -
        >
121 -
          <View style={styles.logoContainer}>
122 -
            <Animated.View style={{ transform: [{ rotate: spin }] }}>
123 -
              <Image
124 -
                source={require("@/assets/images/logo.png")}
125 -
                style={styles.logo}
126 -
                resizeMode="contain"
127 -
              />
128 -
            </Animated.View>
129 -
          </View>
130 -

131 -
          <View style={styles.header}>
132 -
            <Text style={[styles.title, { color: textColor }]} testID="text-welcome">
133 -
              Bem vindo de volta
134 -
            </Text>
135 -
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
136 -
              Logue na sua conta nexo
137 -
            </Text>
138 -
          </View>
139 -

140 -
          <View style={styles.form}>
141 -
            <Input
142 -
              label="Email"
143 -
              type="email"
144 -
              value={email}
145 -
              onChangeText={setEmail}
146 -
              placeholder="Qual seu email?"
147 -
              error={errors.email}
148 -
              autoCapitalize="none"
102 +
    <>
103 +
      <StatusBar style="dark" />
104 +
      <AuthScreen
105 +
        header={
106 +
          <Animated.View
107 +
            style={[styles.logoContainer, { transform: [{ rotate: spin }] }]}
108 +
          >
109 +
            <Image
110 +
              source={require("@/assets/images/logo.png")}
111 +
              style={styles.logo}
112 +
              resizeMode="contain"
149 113
            />
150 -

151 -
            <Input
152 -
              label="Senha"
153 -
              type="password"
154 -
              value={password}
155 -
              onChangeText={setPassword}
156 -
              placeholder="Qual sua senha?"
157 -
              error={errors.password}
158 -
              showPasswordToggle
159 -
              testID="input-password"
160 -
            />
161 -

162 -
            <Button
163 -
              title="Entrar"
164 -
              onPress={handleLogin}
165 -
              loading={loading}
166 -
              style={styles.loginButton}
167 -
              testID="button-login"
168 -
            />
169 -

170 -
            <Link
171 -
              href="/auth/forgot-password"
172 -
              style={StyleSheet.flatten([styles.link, { color: tintColor }])}
173 -
              asChild
174 -
            >
175 -
              <Text
176 -
                style={StyleSheet.flatten([styles.footerText, { color: textColor, opacity: 0.7 }])}
177 -
              >
178 -
                Esqueceu a senha?
179 -
              </Text>
180 -
            </Link>
181 -
          </View>
182 -

114 +
          </Animated.View>
115 +
        }
116 +
        title="Bem vindo de volta"
117 +
        titleTestID="text-welcome"
118 +
        subtitle="Logue na sua conta nexo"
119 +
        footer={
183 120
          <View style={styles.footer}>
184 -
            <Text
185 -
              style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
186 -
            >
121 +
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
187 122
              Não tem uma conta ainda?{" "}
188 123
            </Text>
189 -

190 124
            <Link
191 125
              href="/auth/signup"
192 126
              asChild
193 127
              style={StyleSheet.flatten([styles.link, { color: tintColor }])}
194 128
            >
195 129
              <Text
196 -
                style={StyleSheet.flatten([styles.footerText, { color: textColor, opacity: 0.7 }])}
130 +
                style={StyleSheet.flatten([
131 +
                  styles.footerText,
132 +
                  { color: tintColor },
133 +
                ])}
197 134
              >
198 135
                Cadastre-se
199 136
              </Text>
200 137
            </Link>
201 138
          </View>
202 -
        </ScrollView>
203 -
      </KeyboardAvoidingView>
204 -
    </SafeAreaView>
139 +
        }
140 +
      >
141 +
        <View style={styles.form}>
142 +
          <Input
143 +
            label="Email"
144 +
            type="email"
145 +
            value={email}
146 +
            onChangeText={setEmail}
147 +
            placeholder="Qual seu email?"
148 +
            error={errors.email}
149 +
            autoCapitalize="none"
150 +
          />
151 +

152 +
          <Input
153 +
            label="Senha"
154 +
            type="password"
155 +
            value={password}
156 +
            onChangeText={setPassword}
157 +
            placeholder="Qual sua senha?"
158 +
            error={errors.password}
159 +
            showPasswordToggle
160 +
            testID="input-password"
161 +
          />
162 +

163 +
          <Button
164 +
            title="Entrar"
165 +
            onPress={handleLogin}
166 +
            loading={loading}
167 +
            style={styles.loginButton}
168 +
            testID="button-login"
169 +
          />
170 +

171 +
          <Link
172 +
            href="/auth/forgot-password"
173 +
            style={StyleSheet.flatten([styles.link, { color: tintColor }])}
174 +
            asChild
175 +
          >
176 +
            <Text
177 +
              style={StyleSheet.flatten([
178 +
                styles.footerText,
179 +
                { color: textColor, opacity: 0.7 },
180 +
              ])}
181 +
            >
182 +
              Esqueceu a senha?
183 +
            </Text>
184 +
          </Link>
185 +
        </View>
186 +
      </AuthScreen>
187 +
    </>
205 188
  );
206 189
}
207 190

208 191
const styles = StyleSheet.create({
209 -
  container: {
210 -
    flex: 1,
211 -
  },
212 -
  keyboardView: {
213 -
    flex: 1,
214 -
  },
215 192
  logoContainer: {
216 193
    alignItems: "center",
217 194
    marginBottom: 40,
218 195
    marginTop: 10,
219 196
  },
220 197
  logo: {
221 198
    width: 120,
222 199
    height: 120,
223 200
  },
224 -
  scrollContainer: {
225 -
    flexGrow: 1,
226 -
    paddingHorizontal: 24,
227 -
    justifyContent: "center",
228 -
    minHeight: "100%",
229 -
  },
230 -
  header: {
231 -
    alignItems: "center",
232 -
    marginBottom: 40,
233 -
  },
234 -
  title: {
235 -
    fontSize: 28,
236 -
    fontWeight: "bold",
237 -
    marginBottom: 8,
238 -
  },
239 -
  subtitle: {
240 -
    fontSize: 16,
241 -
    textAlign: "center",
242 -
  },
243 201
  form: {
244 202
    marginBottom: 32,
245 203
  },
246 204
  loginButton: {
247 205
    marginTop: 8,
254 212
  },
255 213
  footer: {
256 214
    flexDirection: "row",
257 215
    justifyContent: "center",
258 216
    alignItems: "center",
259 -
    marginTop: "auto",
260 -
    paddingBottom: 40,
261 217
  },
262 218
  footerText: {
263 219
    fontSize: 14,
264 220
  },
265 221
});
frontend/app/auth/reset-password.tsx
1 1
import React, { useState } from 'react';
2 -
import {
3 -
  View,
4 -
  StyleSheet,
5 -
  ScrollView,
6 -
  KeyboardAvoidingView,
7 -
} from 'react-native';
8 -
import { Text } from '@/components/ui/Text';
2 +
import { StyleSheet, View } from 'react-native';
9 3
import { Link, useLocalSearchParams, router } from 'expo-router';
10 -
import { SafeAreaView } from 'react-native-safe-area-context';
11 4
import { useThemeColor } from '@/hooks/use-theme-color';
12 5
import { Button, Input } from '@/components/ui';
6 +
import { Text } from '@/components/ui/Text';
7 +
import { AuthScreen } from '@/components/auth/AuthScreen';
13 8
import { useAuth } from '@/context/AuthContext';
14 9
import { useToast } from '@/context/ToastContext';
15 10
import { translateError } from '@/lib/errors/translations';
16 11

17 12
export default function ResetPasswordScreen() {
25 20

26 21
  const { resetPassword } = useAuth();
27 22
  const { showToast } = useToast();
28 23
  const [loading, setLoading] = useState(false);
29 24
  const textColor = useThemeColor({}, 'text');
30 -
  const backgroundColor = useThemeColor({}, 'background');
31 25
  const tintColor = useThemeColor({}, 'tint');
32 26

33 27
  const validateForm = () => {
34 28
    const newErrors: typeof errors = {};
35 29

68 62
      setLoading(false);
69 63
    }
70 64
  };
71 65

72 66
  return (
73 -
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
74 -
      <KeyboardAvoidingView
75 -
        behavior='height'
76 -
        style={styles.keyboardView}
77 -
      >
78 -
        <ScrollView
79 -
          contentContainerStyle={styles.scrollContainer}
80 -
          showsVerticalScrollIndicator={false}
81 -
        >
82 -
          <View style={styles.header}>
83 -
            <Text style={[styles.title, { color: textColor }]}>Recuperar a conta</Text>
84 -
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
85 -
              Digite uma nova senha para sua conta
67 +
    <AuthScreen
68 +
      title="Recuperar a conta"
69 +
      subtitle="Digite uma nova senha para sua conta"
70 +
      footer={
71 +
        <View style={styles.footer}>
72 +
          <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
73 +
            Lembrou da senha?{' '}
74 +
          </Text>
75 +
          <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
76 +
            <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
77 +
              Faça login
86 78
            </Text>
87 -
          </View>
88 -

89 -
          <View style={styles.form}>
90 -
            <Input
91 -
              label="Nova senha"
92 -
              type="password"
93 -
              value={password}
94 -
              onChangeText={setPassword}
95 -
              placeholder="Digite uma nova senha"
96 -
              error={errors.password}
97 -
              showPasswordToggle
98 -
            />
99 -

100 -
            <Input
101 -
              label="Confirmar nova senha"
102 -
              type="password"
103 -
              value={confirmPassword}
104 -
              onChangeText={setConfirmPassword}
105 -
              placeholder="Confirme a nova senha"
106 -
              error={errors.confirmPassword}
107 -
              showPasswordToggle
108 -
            />
79 +
          </Link>
80 +
        </View>
81 +
      }
82 +
    >
83 +
      <View style={styles.form}>
84 +
        <Input
85 +
          label="Nova senha"
86 +
          type="password"
87 +
          value={password}
88 +
          onChangeText={setPassword}
89 +
          placeholder="Digite uma nova senha"
90 +
          error={errors.password}
91 +
          showPasswordToggle
92 +
        />
109 93

110 -
            <Button
111 -
              title="Trocar senha"
112 -
              onPress={handleResetPassword}
113 -
              loading={loading}
114 -
              style={styles.resetButton}
115 -
            />
116 -
          </View>
94 +
        <Input
95 +
          label="Confirmar nova senha"
96 +
          type="password"
97 +
          value={confirmPassword}
98 +
          onChangeText={setConfirmPassword}
99 +
          placeholder="Confirme a nova senha"
100 +
          error={errors.confirmPassword}
101 +
          showPasswordToggle
102 +
        />
117 103

118 -
          <View style={styles.footer}>
119 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
120 -
              Lembrou da senha?{' '}
121 -
            </Text>
122 -
            <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
123 -
              <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
124 -
                Faça login
125 -
              </Text>
126 -
            </Link>
127 -
          </View>
128 -
        </ScrollView>
129 -
      </KeyboardAvoidingView>
130 -
    </SafeAreaView>
104 +
        <Button
105 +
          title="Trocar senha"
106 +
          onPress={handleResetPassword}
107 +
          loading={loading}
108 +
          style={styles.resetButton}
109 +
        />
110 +
      </View>
111 +
    </AuthScreen>
131 112
  );
132 113
}
133 114

134 115
const styles = StyleSheet.create({
135 -
  container: {
136 -
    flex: 1,
137 -
  },
138 -
  keyboardView: {
139 -
    flex: 1,
140 -
  },
141 -
  scrollContainer: {
142 -
    flexGrow: 1,
143 -
    paddingHorizontal: 24,
144 -
    justifyContent: 'center',
145 -
    minHeight: '100%',
146 -
  },
147 -
  header: {
148 -
    alignItems: 'center',
149 -
    marginBottom: 40,
150 -
  },
151 -
  title: {
152 -
    fontSize: 28,
153 -
    fontWeight: 'bold',
154 -
    marginBottom: 8,
155 -
  },
156 -
  subtitle: {
157 -
    fontSize: 16,
158 -
    textAlign: 'center',
159 -
  },
160 116
  form: {
161 117
    marginBottom: 32,
162 118
  },
163 119
  resetButton: {
164 120
    marginTop: 8,
170 126
  },
171 127
  footer: {
172 128
    flexDirection: 'row',
173 129
    justifyContent: 'center',
174 130
    alignItems: 'center',
175 -
    marginTop: 'auto',
176 -
    paddingBottom: 20,
177 131
  },
178 132
  footerText: {
179 133
    fontSize: 14,
180 134
  },
181 135
});
frontend/app/auth/signup.tsx
1 1
import React, { useState } from 'react';
2 -
import {
3 -
  View,
4 -
  StyleSheet,
5 -
  ScrollView,
6 -
  KeyboardAvoidingView,
7 -
} from 'react-native';
8 -
import { Text } from '@/components/ui/Text';
2 +
import { Pressable, StyleSheet, View } from 'react-native';
3 +
import { Ionicons } from '@expo/vector-icons';
9 4
import { Link, router } from 'expo-router';
10 -
import { SafeAreaView } from 'react-native-safe-area-context';
11 5
import { useThemeColor } from '@/hooks/use-theme-color';
12 6
import { Button, Input } from '@/components/ui';
7 +
import { Text } from '@/components/ui/Text';
8 +
import { AuthScreen } from '@/components/auth/AuthScreen';
13 9
import { useAuth } from '@/context/AuthContext';
14 10
import { ApiError } from '@/lib/api';
15 11
import { useToast } from '@/context/ToastContext';
16 12
import { translateError, translateFields } from '@/lib/errors/translations';
17 13

19 15
  const [firstName, setFirstName] = useState('');
20 16
  const [lastName, setLastName] = useState('');
21 17
  const [email, setEmail] = useState('');
22 18
  const [password, setPassword] = useState('');
23 19
  const [confirmPassword, setConfirmPassword] = useState('');
20 +
  const [acceptedTerms, setAcceptedTerms] = useState(false);
24 21
  const [errors, setErrors] = useState<{
25 22
    firstName?: string;
26 23
    email?: string;
27 24
    password?: string;
28 25
    confirmPassword?: string;
26 +
    acceptedTerms?: string;
29 27
  }>({});
30 28

31 29
  const [loading, setLoading] = useState(false);
32 30
  const { signup } = useAuth();
33 31
  const { showToast } = useToast();
34 32
  const textColor = useThemeColor({}, 'text');
35 -
  const backgroundColor = useThemeColor({}, 'background');
36 33
  const tintColor = useThemeColor({}, 'tint');
37 34

38 35
  const validateForm = () => {
39 36
    const newErrors: typeof errors = {};
40 37

58 55
      newErrors.confirmPassword = 'Por favor confirme sua senha';
59 56
    } else if (password !== confirmPassword) {
60 57
      newErrors.confirmPassword = 'As senhas não coincidem';
61 58
    }
62 59

60 +
    if (!acceptedTerms) {
61 +
      newErrors.acceptedTerms = 'Você precisa aceitar os termos para continuar';
62 +
    }
63 +

63 64
    setErrors(newErrors);
64 65
    return Object.keys(newErrors).length === 0;
65 66
  };
66 67

67 68
  const handleSignup = async () => {
68 69
    if (!validateForm()) return;
69 70

70 71
    setLoading(true);
71 72
    try {
72 -
      await signup({ firstName, lastName, email, password });
73 +
      await signup({ firstName, lastName, email, password, acceptedTerms });
73 74
      router.replace('/');
74 75
    } catch (error) {
75 76
      if (error instanceof ApiError && error.fields) {
76 77
        setErrors(translateFields(error.fields));
77 78
      } else {
81 82
      setLoading(false);
82 83
    }
83 84
  };
84 85

85 86
  return (
86 -
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
87 -
      <KeyboardAvoidingView
88 -
        behavior='height'
89 -
        style={styles.keyboardView}
90 -
      >
91 -
        <ScrollView
92 -
          contentContainerStyle={styles.scrollContainer}
93 -
          showsVerticalScrollIndicator={false}
94 -
        >
95 -
          <View style={styles.header}>
96 -
            <Text style={[styles.title, { color: textColor }]}>Criar conta</Text>
97 -
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
98 -
              Cadastre-se para começar a monitorar o seu humor.
87 +
    <AuthScreen
88 +
      title="Criar conta"
89 +
      subtitle="Cadastre-se para começar a monitorar o seu humor."
90 +
      footer={
91 +
        <View style={styles.footer}>
92 +
          <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
93 +
            Já tem uma conta?{' '}
94 +
          </Text>
95 +
          <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
96 +
            <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
97 +
              Faça login
99 98
            </Text>
100 -
          </View>
99 +
          </Link>
100 +
        </View>
101 +
      }
102 +
    >
103 +
      <View style={styles.form}>
104 +
        <Input
105 +
          label="Nome"
106 +
          value={firstName}
107 +
          onChangeText={setFirstName}
108 +
          placeholder="Digite seu nome"
109 +
          error={errors.firstName}
110 +
        />
101 111

102 -
          <View style={styles.form}>
103 -
            <Input
104 -
              label="Nome"
105 -
              value={firstName}
106 -
              onChangeText={setFirstName}
107 -
              placeholder="Digite seu nome"
108 -
              error={errors.firstName}
109 -
            />
112 +
        <Input
113 +
          label="Sobrenome (opcional)"
114 +
          value={lastName}
115 +
          onChangeText={setLastName}
116 +
          placeholder="Digite seu sobrenome"
117 +
        />
110 118

111 -
            <Input
112 -
              label="Sobrenome (opcional)"
113 -
              value={lastName}
114 -
              onChangeText={setLastName}
115 -
              placeholder="Digite seu sobrenome"
116 -
            />
119 +
        <Input
120 +
          label="Email"
121 +
          type="email"
122 +
          value={email}
123 +
          onChangeText={setEmail}
124 +
          placeholder="Qual seu email?"
125 +
          error={errors.email}
126 +
          autoCapitalize="none"
127 +
        />
117 128

118 -
            <Input
119 -
              label="Email"
120 -
              type="email"
121 -
              value={email}
122 -
              onChangeText={setEmail}
123 -
              placeholder="Qual seu email?"
124 -
              error={errors.email}
125 -
              autoCapitalize="none"
126 -
            />
129 +
        <Input
130 +
          label="Senha"
131 +
          type="password"
132 +
          value={password}
133 +
          onChangeText={setPassword}
134 +
          placeholder="Deve ter no min. 6 caracteres"
135 +
          error={errors.password}
136 +
          showPasswordToggle
137 +
        />
127 138

128 -
            <Input
129 -
              label="Senha"
130 -
              type="password"
131 -
              value={password}
132 -
              onChangeText={setPassword}
133 -
              placeholder="Deve ter no min. 6 caracteres"
134 -
              error={errors.password}
135 -
              showPasswordToggle
136 -
            />
139 +
        <Input
140 +
          label="Confirme a senha"
141 +
          type="password"
142 +
          value={confirmPassword}
143 +
          onChangeText={setConfirmPassword}
144 +
          placeholder="Confirme sua senha"
145 +
          error={errors.confirmPassword}
146 +
          showPasswordToggle
147 +
        />
137 148

138 -
            <Input
139 -
              label="Confirme a senha"
140 -
              type="password"
141 -
              value={confirmPassword}
142 -
              onChangeText={setConfirmPassword}
143 -
              placeholder="Confirme sua senha"
144 -
              error={errors.confirmPassword}
145 -
              showPasswordToggle
149 +
        <View style={styles.termsRow}>
150 +
          <Pressable
151 +
            onPress={() => setAcceptedTerms((value) => !value)}
152 +
            hitSlop={8}
153 +
            accessibilityRole="checkbox"
154 +
            accessibilityState={{ checked: acceptedTerms }}
155 +
            testID="checkbox-terms"
156 +
          >
157 +
            <Ionicons
158 +
              name={acceptedTerms ? 'checkbox' : 'square-outline'}
159 +
              size={22}
160 +
              color={acceptedTerms ? tintColor : textColor}
146 161
            />
162 +
          </Pressable>
147 163

148 -
            <Button
149 -
              title="Criar conta"
150 -
              onPress={handleSignup}
151 -
              loading={loading}
152 -
              style={styles.signupButton}
153 -
            />
154 -
          </View>
164 +
          <Text style={[styles.termsText, { color: textColor }]}>
165 +
            Li e aceito os{' '}
166 +
          </Text>
155 167

156 -
          <View style={styles.footer}>
157 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
158 -
              Já tem uma conta?{' '}
168 +
          <Link href="/terms" asChild style={StyleSheet.flatten([styles.termsLink, { color: tintColor }])}>
169 +
            <Text style={StyleSheet.flatten([styles.termsLink, { color: tintColor }])}>
170 +
              Termos e Condições
159 171
            </Text>
160 -
            <Link href="/auth/login" asChild style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
161 -
              <Text style={StyleSheet.flatten([styles.link, { color: tintColor }])}>
162 -
                Faça login
163 -
              </Text>
164 -
            </Link>
165 -
          </View>
166 -
        </ScrollView>
167 -
      </KeyboardAvoidingView>
168 -
    </SafeAreaView>
172 +
          </Link>
173 +
        </View>
174 +

175 +
        {errors.acceptedTerms && (
176 +
          <Text style={[styles.termsError, { color: '#ef4444' }]}>
177 +
            {errors.acceptedTerms}
178 +
          </Text>
179 +
        )}
180 +

181 +
        <Button
182 +
          title="Criar conta"
183 +
          onPress={handleSignup}
184 +
          loading={loading}
185 +
          style={styles.signupButton}
186 +
        />
187 +
      </View>
188 +
    </AuthScreen>
169 189
  );
170 190
}
171 191

172 192
const styles = StyleSheet.create({
173 -
  container: {
174 -
    flex: 1,
175 -
  },
176 -
  keyboardView: {
177 -
    flex: 1,
193 +
  form: {
194 +
    marginBottom: 32,
178 195
  },
179 -
  scrollContainer: {
180 -
    flexGrow: 1,
181 -
    paddingHorizontal: 24,
182 -
    justifyContent: 'center',
183 -
    minHeight: '100%',
196 +
  signupButton: {
197 +
    marginTop: 8,
184 198
  },
185 -
  header: {
199 +
  termsRow: {
200 +
    flexDirection: 'row',
186 201
    alignItems: 'center',
187 -
    marginBottom: 40,
188 -
  },
189 -
  title: {
190 -
    fontSize: 28,
191 -
    fontWeight: 'bold',
202 +
    flexWrap: 'wrap',
192 203
    marginBottom: 8,
193 204
  },
194 -
  subtitle: {
195 -
    fontSize: 16,
196 -
    textAlign: 'center',
205 +
  termsText: {
206 +
    fontSize: 14,
197 207
  },
198 -
  form: {
199 -
    marginBottom: 32,
208 +
  termsLink: {
209 +
    fontSize: 14,
210 +
    fontWeight: '600',
200 211
  },
201 -
  signupButton: {
202 -
    marginTop: 8,
212 +
  termsError: {
213 +
    fontSize: 12,
214 +
    marginBottom: 8,
203 215
  },
204 216
  link: {
205 217
    textAlign: 'center',
206 218
    fontSize: 14,
207 219
    fontWeight: '500',
208 220
  },
209 221
  footer: {
210 222
    flexDirection: 'row',
211 223
    justifyContent: 'center',
212 224
    alignItems: 'center',
213 -
    marginTop: 'auto',
214 -
    paddingBottom: 20,
215 225
  },
216 226
  footerText: {
217 227
    fontSize: 14,
218 228
  },
219 229
});
/dev/null → frontend/components/auth/AuthScreen.tsx
1 +
import React from 'react';
2 +
import {
3 +
  KeyboardAvoidingView,
4 +
  ScrollView,
5 +
  StyleSheet,
6 +
  View,
7 +
  type StyleProp,
8 +
  type ViewStyle,
9 +
} from 'react-native';
10 +
import { SafeAreaView } from 'react-native-safe-area-context';
11 +
import { Text } from '@/components/ui/Text';
12 +
import { useThemeColor } from '@/hooks/use-theme-color';
13 +

14 +
interface AuthScreenProps {
15 +
  title?: string;
16 +
  subtitle?: string;
17 +
  titleTestID?: string;
18 +
  // Sits above the title — login puts its animated logo here.
19 +
  header?: React.ReactNode;
20 +
  footer?: React.ReactNode;
21 +
  children?: React.ReactNode;
22 +
  contentContainerStyle?: StyleProp<ViewStyle>;
23 +
}
24 +

25 +
// The one layout every auth screen shares. Top-aligned on purpose: a form whose
26 +
// error rows grow must not re-center (and scroll) the whole screen when it does.
27 +
export function AuthScreen({
28 +
  title,
29 +
  subtitle,
30 +
  titleTestID,
31 +
  header,
32 +
  footer,
33 +
  children,
34 +
  contentContainerStyle,
35 +
}: AuthScreenProps) {
36 +
  const textColor = useThemeColor({}, 'text');
37 +
  const backgroundColor = useThemeColor({}, 'background');
38 +

39 +
  return (
40 +
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
41 +
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
42 +
        <ScrollView
43 +
          contentContainerStyle={[styles.scrollContainer, contentContainerStyle]}
44 +
          showsVerticalScrollIndicator={false}
45 +
          keyboardShouldPersistTaps="handled"
46 +
        >
47 +
          {header}
48 +

49 +
          {(title || subtitle) && (
50 +
            <View style={styles.header}>
51 +
              {title && (
52 +
                <Text testID={titleTestID} style={[styles.title, { color: textColor }]}>
53 +
                  {title}
54 +
                </Text>
55 +
              )}
56 +
              {subtitle && (
57 +
                <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
58 +
                  {subtitle}
59 +
                </Text>
60 +
              )}
61 +
            </View>
62 +
          )}
63 +

64 +
          {children}
65 +

66 +
          {footer && <View style={styles.footer}>{footer}</View>}
67 +
        </ScrollView>
68 +
      </KeyboardAvoidingView>
69 +
    </SafeAreaView>
70 +
  );
71 +
}
72 +

73 +
const styles = StyleSheet.create({
74 +
  container: {
75 +
    flex: 1,
76 +
  },
77 +
  keyboardView: {
78 +
    flex: 1,
79 +
  },
80 +
  scrollContainer: {
81 +
    flexGrow: 1,
82 +
    paddingHorizontal: 24,
83 +
    paddingTop: 24,
84 +
    paddingBottom: 24,
85 +
  },
86 +
  header: {
87 +
    alignItems: 'center',
88 +
    marginBottom: 40,
89 +
  },
90 +
  title: {
91 +
    fontSize: 28,
92 +
    fontWeight: 'bold',
93 +
    marginBottom: 8,
94 +
    textAlign: 'center',
95 +
  },
96 +
  subtitle: {
97 +
    fontSize: 16,
98 +
    textAlign: 'center',
99 +
    lineHeight: 24,
100 +
  },
101 +
  footer: {
102 +
    marginTop: 'auto',
103 +
    paddingTop: 24,
104 +
  },
105 +
});