frontend: tidy the app shell after the SDK 57 upgrade
Tabs now keep their screens attached and frozen while hidden, the Android form sheets slide in instead of appearing instantly, and ditch iOS stuff Also lifts the theme plumbing into the root layout, hoists ScreenLayout's styles out of the render path, and drops a duplicate imperative auth redirect that raced the declarative one
Pedro Lucas Porcellis porcellis@eletrotupi.com · 1 month ago
1495e32eb83d60fb8dd6a1c539de3fbefa9e19b1 Browse filesParents
88bac76Changed files
5
Additions
+98
Deletions
-107
frontend/app/(tabs)/_layout.tsx
1 1
import { Tabs } from "expo-router/js-tabs";
2 -
import { Redirect, router } from "expo-router";
3 -
import React, { useEffect } from "react";
2 +
import { Redirect } from "expo-router";
4 3
import { View, ActivityIndicator } from "react-native";
5 4
6 5
import { HapticTab } from "@/components/misc/haptic-tab";
7 6
import { IconSymbol } from "@/components/ui/icon-symbol";
8 7
import { Colors } from "@/constants/theme";
9 -
import { useColorScheme } from "@/hooks/use-color-scheme";
10 8
import { useAuth } from "@/context/AuthContext";
11 9
12 10
export default function TabLayout() {
13 -
const colorScheme = useColorScheme();
14 11
const { isAuthenticated, isLoading } = useAuth();
15 12
16 -
useEffect(() => {
17 -
if (!isLoading && !isAuthenticated) {
18 -
router.replace("/auth/login");
19 -
}
20 -
}, [isAuthenticated, isLoading]);
21 -
22 13
if (isLoading) {
23 14
return (
24 15
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
25 16
<ActivityIndicator size="large" />
26 17
</View>
36 27
screenOptions={{
37 28
tabBarInactiveTintColor: Colors["light"].tabIconDefault,
38 29
tabBarActiveTintColor: Colors["light"].tabIconSelected,
39 30
headerShown: false,
40 31
tabBarButton: HapticTab,
32 +
freezeOnBlur: true,
41 33
}}
42 34
>
43 35
<Tabs.Screen
44 36
name="history"
45 37
options={{
46 38
title: "Histórico",
47 39
tabBarIcon: ({ color }) => (
48 40
<IconSymbol size={28} name="history" color={color} />
49 41
),
42 +
tabBarButton: (props) => <HapticTab {...props} testID="tab-history" />,
50 43
}}
51 44
/>
52 45
53 46
<Tabs.Screen
54 47
name="insights"
55 48
options={{
56 49
title: "Jornada",
57 50
tabBarIcon: ({ color }) => (
58 51
<IconSymbol size={28} name="book" color={color} />
59 52
),
53 +
tabBarButton: (props) => <HapticTab {...props} testID="tab-journey" />,
60 54
}}
61 55
/>
62 56
63 57
<Tabs.Screen
64 58
name="new"
65 59
options={{
66 60
title: "Novo",
67 61
tabBarIcon: ({ color }) => (
68 62
<IconSymbol size={28} name="add-circle" color={color} />
69 63
),
64 +
tabBarButton: (props) => <HapticTab {...props} testID="tab-new" />,
70 65
}}
71 66
/>
72 67
73 68
<Tabs.Screen
74 69
name="actions"
75 70
options={{
76 71
title: "Ações",
77 72
tabBarIcon: ({ color }) => (
78 73
<IconSymbol size={28} name="bolt" color={color} />
79 74
),
75 +
tabBarButton: (props) => <HapticTab {...props} testID="tab-actions" />,
80 76
}}
81 77
/>
82 78
83 79
<Tabs.Screen
84 80
name="settings"
85 81
options={{
86 82
title: "Ajustes",
87 83
tabBarIcon: ({ color }) => (
88 84
<IconSymbol size={28} name="settings.fill" color={color} />
89 85
),
86 +
tabBarButton: (props) => <HapticTab {...props} testID="tab-settings" />,
90 87
}}
91 88
/>
92 89
</Tabs>
93 90
);
94 91
}
frontend/app/_layout.tsx
1 1
import {
2 2
DarkTheme,
3 3
DefaultTheme,
4 4
Stack,
5 5
ThemeProvider,
23 23
export default function RootLayout() {
24 24
const colorScheme = useColorScheme();
25 25
26 26
return (
27 27
<QueryClientProvider client={queryClient}>
28 -
<ThemeProvider value={DefaultTheme}>
28 +
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
29 29
<ToastProvider>
30 30
<AuthProvider>
31 31
<AppBootstrap />
32 32
<Stack>
33 33
<Stack.Screen name="index" options={{ headerShown: false }} />
52 52
options={{
53 53
presentation: "formSheet",
54 54
title: "Editar Componentes",
55 55
sheetAllowedDetents: [0.25, 0.5, 1],
56 56
sheetInitialDetentIndex: 1,
57 +
animation: "slide_from_bottom",
57 58
}}
58 59
/>
59 60
60 61
<Stack.Screen
61 62
name="care-actions/post-appointment"
81 82
options={{
82 83
presentation: "formSheet",
83 84
title: "Registrar Ação de Cuidado",
84 85
sheetAllowedDetents: [0.25, 0.5, 0.7],
85 86
sheetInitialDetentIndex: 1,
87 +
animation: "slide_from_bottom",
86 88
}}
87 89
/>
88 90
<Stack.Screen
89 91
name="care-actions/medicine"
90 92
options={{ title: "Medicamento" }}
124 126
</ToastProvider>
125 127
</ThemeProvider>
126 128
</QueryClientProvider>
127 129
);
128 130
}
frontend/components/misc/haptic-tab.tsx
1 1
import { BottomTabBarButtonProps } from 'expo-router/js-tabs';
2 -
import { Pressable } from 'react-native';
2 +
import { Platform, Pressable } from 'react-native';
3 3
import type { Ref } from 'react';
4 4
import type { View } from 'react-native';
5 5
import * as Haptics from 'expo-haptics';
6 6
7 7
export function HapticTab(props: BottomTabBarButtonProps) {
9 9
return (
10 10
<Pressable
11 11
{...rest}
12 12
ref={ref as Ref<View>}
13 13
onPressIn={(ev) => {
14 +
if (Platform.OS === "ios") {
15 +
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
16 +
}
14 17
onPressIn?.(ev);
15 18
}}
16 19
/>
17 20
);
18 21
}frontend/components/ui/ScreenLayout.tsx
1 -
import React from "react";
2 -
3 1
import {
4 2
View,
5 3
ScrollView,
6 4
StyleSheet,
7 5
Image,
32 30
onNotificationPress,
33 31
showNotificationBadge = false,
34 32
scrollEnabled = true,
35 33
}) => {
36 34
const router = useRouter();
37 -
const textColor = useThemeColor({}, "text");
38 -
const backgroundColor = useThemeColor({}, "background");
39 -
const surfaceColor = useThemeColor({}, "surface");
40 -
const dividerColor = useThemeColor({}, "divider");
41 -
const accentBlue = useThemeColor({}, "accentBlue");
42 -
const tintColor = useThemeColor({}, "tint");
43 -
44 -
const styles = StyleSheet.create({
45 -
container: {
46 -
flex: 1,
47 -
backgroundColor: backgroundColor,
48 -
},
49 -
safeAreaView: {
50 -
flex: 1,
51 -
backgroundColor: backgroundColor,
52 -
},
53 -
header: {
54 -
paddingHorizontal: Spacing.containerPadding,
55 -
paddingVertical: 12,
56 -
backgroundColor: surfaceColor,
57 -
borderBottomWidth: 1,
58 -
borderBottomColor: dividerColor,
59 -
flexDirection: "row",
60 -
justifyContent: "space-between",
61 -
alignItems: "center",
62 -
},
63 -
headerLeft: {
64 -
flexDirection: "row",
65 -
alignItems: "center",
66 -
gap: Spacing.inlineGapSm,
67 -
},
68 -
avatar: {
69 -
width: 40,
70 -
height: 40,
71 -
borderRadius: 20,
72 -
backgroundColor: accentBlue,
73 -
},
74 -
userName: {
75 -
fontSize: Typography.bodyLg.fontSize,
76 -
fontWeight: "600",
77 -
color: textColor,
78 -
},
79 -
notificationButton: {
80 -
width: 40,
81 -
height: 40,
82 -
borderRadius: BorderRadius.md,
83 -
justifyContent: "center",
84 -
alignItems: "center",
85 -
activeOpacity: 0.7,
86 -
},
87 -
notificationBadge: {
88 -
position: "absolute",
89 -
top: 4,
90 -
right: 4,
91 -
width: 8,
92 -
height: 8,
93 -
borderRadius: 4,
94 -
backgroundColor: tintColor,
95 -
},
96 -
scrollContent: {
97 -
paddingHorizontal: Spacing.containerPadding,
98 -
paddingVertical: Spacing.sectionGap,
99 -
},
100 -
});
35 +
const textColor = useThemeColor({}, "text") as string;
36 +
const backgroundColor = useThemeColor({}, "background") as string;
37 +
const surfaceColor = useThemeColor({}, "surface") as string;
38 +
const dividerColor = useThemeColor({}, "divider") as string;
39 +
const accentBlue = useThemeColor({}, "accentBlue") as string;
40 +
const tintColor = useThemeColor({}, "tint") as string;
101 41
102 42
return (
103 -
<View style={styles.container}>
43 +
<View style={[styles.container, { backgroundColor }]}>
104 44
<StatusBar barStyle="dark-content" />
105 45
<SafeAreaView
106 -
style={styles.safeAreaView}
46 +
style={[styles.safeAreaView, { backgroundColor }]}
107 47
edges={["top", "left", "right"]}
108 48
>
109 -
<View style={styles.header}>
49 +
<View style={[styles.header, { backgroundColor: surfaceColor, borderBottomColor: dividerColor }]}>
110 50
<View style={styles.headerLeft}>
111 51
{userAvatar ? (
112 -
<Image source={{ uri: userAvatar }} style={styles.avatar} />
52 +
<Image source={{ uri: userAvatar }} style={[styles.avatar, { backgroundColor: accentBlue }]} />
113 53
) : (
114 -
<View style={styles.avatar} />
54 +
<View style={[styles.avatar, { backgroundColor: accentBlue }]} />
115 55
)}
116 56
<View>
117 -
<Text style={styles.userName}>Olá, {userName}</Text>
57 +
<Text style={[styles.userName, { color: textColor }]}>Olá, {userName}</Text>
118 58
</View>
119 59
</View>
120 60
121 61
<Pressable
122 62
style={styles.notificationButton}
123 63
onPress={onNotificationPress ?? (() => router.push("/notifications"))}
124 64
hitSlop={8}
125 65
>
126 -
<View
127 -
style={{
128 -
fontSize: 24,
129 -
color: textColor,
130 -
}}
131 -
>
132 -
<IconSymbol size={24} name="notifications" color={textColor} />
133 -
</View>
134 -
{showNotificationBadge && <View style={styles.notificationBadge} />}
66 +
<IconSymbol size={24} name="notifications" color={textColor} />
67 +
{showNotificationBadge && (
68 +
<View style={[styles.notificationBadge, { backgroundColor: tintColor }]} />
69 +
)}
135 70
</Pressable>
136 71
</View>
137 72
138 73
<ScrollView
139 74
scrollEnabled={scrollEnabled}
145 80
</ScrollView>
146 81
</SafeAreaView>
147 82
</View>
148 83
);
149 84
};
85 +
86 +
const styles = StyleSheet.create({
87 +
container: {
88 +
flex: 1,
89 +
},
90 +
safeAreaView: {
91 +
flex: 1,
92 +
},
93 +
header: {
94 +
paddingHorizontal: Spacing.containerPadding,
95 +
paddingVertical: 12,
96 +
borderBottomWidth: 1,
97 +
flexDirection: "row",
98 +
justifyContent: "space-between",
99 +
alignItems: "center",
100 +
},
101 +
headerLeft: {
102 +
flexDirection: "row",
103 +
alignItems: "center",
104 +
gap: Spacing.inlineGapSm,
105 +
},
106 +
avatar: {
107 +
width: 40,
108 +
height: 40,
109 +
borderRadius: 20,
110 +
},
111 +
userName: {
112 +
fontSize: Typography.bodyLg.fontSize,
113 +
fontWeight: "600",
114 +
},
115 +
notificationButton: {
116 +
width: 40,
117 +
height: 40,
118 +
borderRadius: BorderRadius.md,
119 +
justifyContent: "center",
120 +
alignItems: "center",
121 +
},
122 +
notificationBadge: {
123 +
position: "absolute",
124 +
top: 4,
125 +
right: 4,
126 +
width: 8,
127 +
height: 8,
128 +
borderRadius: 4,
129 +
},
130 +
scrollContent: {
131 +
paddingHorizontal: Spacing.containerPadding,
132 +
paddingVertical: Spacing.sectionGap,
133 +
},
134 +
});
150 135
151 136
export default ScreenLayout;
frontend/context/AuthContext.tsx
1 -
import React, { createContext, useContext, useEffect, useState } from "react";
1 +
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
2 2
import { apiClient, User } from "@/lib/api";
3 3
import { queryClient, storage } from "@/lib/queryClient";
4 4
5 5
interface AuthProviderProps {
6 6
children: React.ReactNode;
148 148
149 149
const updateAuthUser = (user: User) => {
150 150
setUser(user);
151 151
};
152 152
153 -
const value: AuthContextType = {
154 -
user,
155 -
isLoading,
156 -
isAuthenticated,
157 -
login,
158 -
signup,
159 -
logout,
160 -
forgotPassword,
161 -
resetPassword,
162 -
updateAuthUser,
163 -
activate,
164 -
requestActivateCode,
165 -
};
153 +
const value = useMemo<AuthContextType>(
154 +
() => ({
155 +
user,
156 +
isLoading,
157 +
isAuthenticated,
158 +
login,
159 +
signup,
160 +
logout,
161 +
forgotPassword,
162 +
resetPassword,
163 +
updateAuthUser,
164 +
activate,
165 +
requestActivateCode,
166 +
}),
167 +
// eslint-disable-next-line react-hooks/exhaustive-deps
168 +
[user, isLoading, isAuthenticated],
169 +
);
166 170
167 171
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
168 172
};