eletrotupi / tcc/ commit / 1495e32

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 files
Parents 88bac76
Changed 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
};