diff --git a/src/Navigation.test.tsx b/src/Navigation.test.tsx new file mode 100644 index 0000000..8ad3141 --- /dev/null +++ b/src/Navigation.test.tsx @@ -0,0 +1,52 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import Navigation from '@/Navigation'; +import * as api from '@/api'; +import { AuthProvider } from '@/auth/AuthContext'; +import { queryKeys } from '@/queries/queryKeys'; +import { testUser } from '@/testUtils'; + +// Navigation owns the NavigationContainer, so it can't go through renderWithProviders +const renderNavigation = (token: string | null) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, refetchOnMount: false } }, + }); + if (token) { + queryClient.setQueryData(queryKeys.user, testUser); + } + return render( + + + + + , + ); +}; + +beforeEach(() => { + jest.spyOn(api, 'fetchUserData').mockResolvedValue(testUser); +}); + +it('opens the settings screen from the membership header when logged in', async () => { + const { findByText, findByRole } = await renderNavigation('test-token'); + + // The tab label is the only 'Medlemskap' text: native-stack passes titles to the native header + await fireEvent.press(await findByText('Medlemskap')); + expect(await findByText('MEDLEM')).toBeTruthy(); + + await fireEvent.press(await findByRole('button', { name: 'Innstillinger' })); + + expect(await findByText('Logg ut')).toBeTruthy(); + expect(await findByText('Slett bruker')).toBeTruthy(); +}); + +it('has no settings button when logged out', async () => { + const { findByText, queryByRole } = await renderNavigation(null); + + await fireEvent.press(await findByText('Medlemskap')); + expect(await findByText('Bli medlem')).toBeTruthy(); + + expect(queryByRole('button', { name: 'Innstillinger' })).toBeNull(); +}); diff --git a/src/Navigation.tsx b/src/Navigation.tsx index cd49344..cd0c6ab 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,14 +1,17 @@ import { BottomTabNavigationOptions, createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { createNativeStackNavigator, NativeStackNavigationOptions } from '@react-navigation/native-stack'; import React from 'react'; +import { useAuth } from '@/auth/AuthContext'; import About from '@/modules/about/About'; import EventList from '@/modules/events/EventList'; import Login from '@/modules/login/Login'; import Membership from '@/modules/membership/Membership'; import UserDelete from '@/modules/membership/UserDelete'; import UserRegister from '@/modules/register/UserRegister'; +import Settings from '@/modules/settings/Settings'; +import SettingsHeaderButton from '@/modules/settings/SettingsHeaderButton'; import theme from '@/theme'; import type { EventStackParamList, HomeTabParamList, UserStackParamList } from '@/types/navigation'; import IconEvent from '@assets/icons/event.svg'; @@ -39,27 +42,40 @@ const EventStackScreen = () => ( ); +type HeaderRightType = NativeStackNavigationOptions['headerRight']; +const SettingsHeaderRight: HeaderRightType = ({ tintColor }) => ; + const UserStack = createNativeStackNavigator(); -const UserStackScreen = () => ( - - - - - - -); +const UserStackScreen = () => { + const { isAuthenticated } = useAuth(); + + return ( + + + + + + + + ); +}; type TabBarIconType = BottomTabNavigationOptions['tabBarIcon']; diff --git a/src/components/ListRow.tsx b/src/components/ListRow.tsx new file mode 100644 index 0000000..b361ada --- /dev/null +++ b/src/components/ListRow.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { AccessibilityRole, Pressable, StyleSheet, Text, View } from 'react-native'; + +import theme from '@/theme'; + +export type ListRowProps = { + label: string; + onPress?: () => void; + /** Right-hand element, e.g. a Switch */ + accessory?: React.ReactNode; + /** Disclosure glyph, for rows that lead somewhere */ + chevron?: boolean; + destructive?: boolean; + accessibilityRole?: AccessibilityRole; + testID?: string; +}; + +/** One row in a ListSection: a label with an optional accessory, pressable when given onPress */ +const ListRow = ({ label, onPress, accessory, chevron, destructive, accessibilityRole, testID }: ListRowProps) => { + const content = ( + <> + {label} + {accessory} + {chevron && ›} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + [styles.row, pressed && styles.pressed]} + testID={testID} + > + {content} + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 16, + backgroundColor: theme.colors.neufWhite, + }, + pressed: { + opacity: 0.7, + }, + label: { + flex: 1, + fontSize: 16, + }, + destructive: { + color: theme.colors.danger, + }, + chevron: { + color: theme.colors.lightText, + fontSize: 22, + lineHeight: 22, + marginLeft: 8, + }, +}); + +export default ListRow; diff --git a/src/components/ListSection.tsx b/src/components/ListSection.tsx new file mode 100644 index 0000000..5528cad --- /dev/null +++ b/src/components/ListSection.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import theme from '@/theme'; + +type ListSectionProps = { + title?: string; + children: React.ReactNode; +}; + +/** Grouped rows on a white card with hairline separators, like a settings screen */ +const ListSection = ({ title, children }: ListSectionProps) => { + const rows = React.Children.toArray(children); + + return ( + + {title && {title}} + + {rows.map((row, index) => ( + // Rows are static per section, so the index is a stable key + + {index > 0 && } + {row} + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + section: { + marginTop: 24, + }, + title: { + color: theme.colors.lightText, + fontSize: 13, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginHorizontal: 32, + marginBottom: 8, + }, + card: { + backgroundColor: theme.colors.neufWhite, + borderRadius: 12, + marginHorizontal: 16, + overflow: 'hidden', + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: theme.colors.gray1, + marginLeft: 16, + }, +}); + +export default ListSection; diff --git a/src/components/PrivacyButton.tsx b/src/components/PrivacyButton.tsx index 960957b..b14a29a 100644 --- a/src/components/PrivacyButton.tsx +++ b/src/components/PrivacyButton.tsx @@ -1,14 +1,11 @@ import React from 'react'; -import { Linking, StyleSheet, Text, TouchableOpacity } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; import theme from '@/theme'; -import { withAppSource } from '@/utils'; +import { openPrivacyPolicy } from '@/utils'; const PrivacyButton = () => ( - Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL || ''))} - > + Personvern ); diff --git a/src/modules/about/About.test.tsx b/src/modules/about/About.test.tsx index 57711eb..7d09850 100644 --- a/src/modules/about/About.test.tsx +++ b/src/modules/about/About.test.tsx @@ -8,4 +8,6 @@ it('renders the about screen', async () => { expect(getByText('Åpningstider')).toBeTruthy(); expect(getByText('Ta kontakt')).toBeTruthy(); + // Store policies want the privacy policy reachable without logging in + expect(getByText('Personvern')).toBeTruthy(); }); diff --git a/src/modules/about/About.tsx b/src/modules/about/About.tsx index ca8b0ac..db28153 100644 --- a/src/modules/about/About.tsx +++ b/src/modules/about/About.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import Button from '@/components/Button'; +import PrivacyButton from '@/components/PrivacyButton'; import theme from '@/theme'; import { withAppSource } from '@/utils'; import logo from '@assets/DNS-logo-svart.png'; @@ -177,6 +178,7 @@ const About = () => ( Alle studentforeninger i Oslo kan arrangere gratis på Chateau Neuf. Skal du arrangere en fest, sette en konsert, holde et foredrag eller gjennomføre et møte, har vi lokaler som kan brukes til dette. + ); diff --git a/src/modules/membership/Membership.test.tsx b/src/modules/membership/Membership.test.tsx index 1395443..1aeb7eb 100644 --- a/src/modules/membership/Membership.test.tsx +++ b/src/modules/membership/Membership.test.tsx @@ -98,14 +98,14 @@ it('signs out when the stored token has been revoked', async () => { expect(await findByText('Bli medlem')).toBeTruthy(); }); -it('logs out and shows the logged-out membership screen again', async () => { - const { findByText } = await renderWithProviders(, { +it('keeps account actions off the proof, behind the settings button', async () => { + const { findByText, queryByText } = await renderWithProviders(, { initialToken: 'test-token', initialUser: testUser, }); - await fireEvent.press(await findByText('Logg ut')); - - expect(await findByText('Bli medlem')).toBeTruthy(); - expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' }); + expect(await findByText('MEDLEM')).toBeTruthy(); + expect(queryByText('Logg ut')).toBeNull(); + expect(queryByText('Slett bruker')).toBeNull(); + expect(queryByText('Varsle meg når medlemskapet utløper')).toBeNull(); }); diff --git a/src/modules/membership/Membership.tsx b/src/modules/membership/Membership.tsx index 1206d3a..8cbcda4 100644 --- a/src/modules/membership/Membership.tsx +++ b/src/modules/membership/Membership.tsx @@ -9,16 +9,7 @@ import Proof from './Proof'; import useMembership from './useMembership'; const Membership = () => { - const { - isPending, - isRefetching, - onRegisterPress, - onLoginPress, - onLogoutPress, - onDeletePress, - isAuthenticated, - fetchUser, - } = useMembership(); + const { isPending, isRefetching, onRegisterPress, onLoginPress, isAuthenticated, fetchUser } = useMembership(); if (isAuthenticated) { // Only the very first fetch blocks, refreshes keep the proof mounted @@ -30,17 +21,8 @@ const Membership = () => { ); } - return ( - <> - - - - ); + // Account actions and the privacy policy live behind the header's settings button + return ; } return ( diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index b994d30..bb96ae3 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -32,14 +32,11 @@ const renderProof = ({ fetchUser = jest.fn(), membershipTypes, }: { user?: UserType; fetchUser?: () => void; membershipTypes?: MembershipTypeOption[] | null } = {}) => - renderWithProviders( - , - { - initialToken: 'test-token', - initialUser: user, - initialMembershipTypes: membershipTypes, - }, - ); + renderWithProviders(, { + initialToken: 'test-token', + initialUser: user, + initialMembershipTypes: membershipTypes, + }); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/modules/membership/Proof.tsx b/src/modules/membership/Proof.tsx index 36ed67d..d3e6eed 100644 --- a/src/modules/membership/Proof.tsx +++ b/src/modules/membership/Proof.tsx @@ -1,5 +1,4 @@ -import * as Notifications from 'expo-notifications'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef } from 'react'; import { Image, ScrollView, @@ -11,7 +10,6 @@ import { ActivityIndicator, Dimensions, GestureResponderEvent, - Switch, } from 'react-native'; import { Confetti, PIConfetti } from 'react-native-fast-confetti'; import type { ConfettiMethods, PIConfettiMethods } from 'react-native-fast-confetti'; @@ -20,9 +18,9 @@ import { Easing } from 'react-native-reanimated'; import Button from '@/components/Button'; import RadioButton from '@/components/RadioButton'; import Toast from '@/components/Toast'; -import { useNotifications } from '@/hooks/useNotifications'; import theme from '@/theme'; import type { UserType } from '@/types/data'; +import { displayName } from '@/utils'; import useProof from './useProof'; import logo from '../../../assets/DNS-logo-svart.png'; @@ -195,71 +193,17 @@ const Logo = ({ user }: { user: UserType }) => { ); }; -const MembershipName = ({ user }: { user: UserType }) => { - let name = `${user.first_name} ${user.last_name}`; - if (name === ' ') { - name = user.email; - } - return ( - - {name} - - ); -}; - -const NotificationToggle = ({ user }: { user: UserType }) => { - const { hasPermission, toggleNotifications, scheduleMembershipExpiryNotification } = useNotifications(); - const [toast, setToast] = useState<{ message: string; visible: boolean } | null>(null); - - const handleToggle = async (enabled: boolean) => { - const { success, needsPermission } = await toggleNotifications(enabled); - if (needsPermission) { - await Notifications.requestPermissionsAsync(); - setToast({ - message: 'Du må godta varsler i innstillinger for å motta påminnelser', - visible: true, - }); - setTimeout(() => setToast(null), 3000); - return; - } - if (success && enabled && user.last_membership?.end_date) { - await scheduleMembershipExpiryNotification(user.last_membership.end_date); - } - }; - - if (!user.is_member || !user.last_membership) { - return null; - } - - return ( - - Varsle meg når medlemskapet utløper - - setToast(null)} - /> - - ); -}; +const MembershipName = ({ user }: { user: UserType }) => ( + + {displayName(user)} + +); interface ProofProps { - onLogoutPress: () => void; fetchUser: () => void; isRefetching: boolean; - onDeletePress: () => void; } -const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofProps) => { +const Proof = ({ fetchUser, isRefetching }: ProofProps) => { const confettiRef = useRef(null); const burstRef = useRef(null); const confettiContainerRef = useRef>(null); @@ -341,17 +285,8 @@ const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofP setMembershipTypeSlug={setMembershipTypeSlug} /> - - - - - { const navigation = useNavigation(); - const { isAuthenticated, signOut } = useAuth(); + const { isAuthenticated } = useAuth(); const { isPending, isRefetching, refetch } = useUser(); - const onLogoutPress = () => { - signOut(); - navigation.navigate('UserStack', { screen: 'Membership' }); - }; const onRegisterPress = () => navigation.navigate('UserStack', { screen: 'UserRegister' }); const onLoginPress = () => navigation.navigate('UserStack', { screen: 'Login' }); - const onDeletePress = () => navigation.navigate('UserStack', { screen: 'UserDelete' }); return { isAuthenticated, isPending, isRefetching, - onLogoutPress, onRegisterPress, - onDeletePress, onLoginPress, fetchUser: refetch, }; diff --git a/src/modules/settings/NotificationRow.tsx b/src/modules/settings/NotificationRow.tsx new file mode 100644 index 0000000..24a5171 --- /dev/null +++ b/src/modules/settings/NotificationRow.tsx @@ -0,0 +1,49 @@ +import * as Notifications from 'expo-notifications'; +import React from 'react'; +import { Switch } from 'react-native'; + +import ListRow from '@/components/ListRow'; +import { useNotifications } from '@/hooks/useNotifications'; +import theme from '@/theme'; + +const LABEL = 'Varsle meg når medlemskapet utløper'; + +type NotificationRowProps = { + /** end_date of the current membership, the reminder fires the day after */ + expiryDate: string; + showToast: (message: string) => void; +}; + +const NotificationRow = ({ expiryDate, showToast }: NotificationRowProps) => { + const { hasPermission, toggleNotifications, scheduleMembershipExpiryNotification } = useNotifications(); + + const handleToggle = async (enabled: boolean) => { + const { success, needsPermission } = await toggleNotifications(enabled); + if (needsPermission) { + await Notifications.requestPermissionsAsync(); + showToast('Du må godta varsler i innstillinger for å motta påminnelser'); + return; + } + if (success && enabled) { + await scheduleMembershipExpiryNotification(expiryDate); + } + }; + + return ( + + } + /> + ); +}; + +export default NotificationRow; diff --git a/src/modules/settings/Settings.test.tsx b/src/modules/settings/Settings.test.tsx new file mode 100644 index 0000000..cd551ec --- /dev/null +++ b/src/modules/settings/Settings.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, waitFor } from '@testing-library/react-native'; +import * as Notifications from 'expo-notifications'; +import React from 'react'; +import { Linking } from 'react-native'; + +import Settings from '@/modules/settings/Settings'; +import { renderWithProviders, testUser } from '@/testUtils'; +import type { UserType } from '@/types/data'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +const NOTIFICATION_LABEL = 'Varsle meg når medlemskapet utløper'; + +const renderSettings = ({ user = testUser, token = 'test-token' }: { user?: UserType; token?: string | null } = {}) => + renderWithProviders(, { initialToken: token, initialUser: token ? user : null }); + +beforeEach(() => { + jest.clearAllMocks(); + // Back to the setup-wide defaults, since earlier tests may have overridden them + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined' }); + (Notifications.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'denied' }); +}); + +it('lists the account, reminder and privacy rows for a member', async () => { + const { findByText, getByText, getByRole } = await renderSettings(); + + expect(await findByText('Test Testesen')).toBeTruthy(); + expect(getByText('test@example.test')).toBeTruthy(); + expect(getByRole('switch', { name: NOTIFICATION_LABEL })).toBeTruthy(); + expect(getByRole('link', { name: 'Personvern' })).toBeTruthy(); + expect(getByRole('button', { name: 'Logg ut' })).toBeTruthy(); + expect(getByRole('button', { name: 'Slett bruker' })).toBeTruthy(); +}); + +it('has no reminder switch for a non-member', async () => { + const nonMember: UserType = { ...testUser, is_member: false, last_membership: null }; + const { findByText, queryByRole, queryByText } = await renderSettings({ user: nonMember }); + + expect(await findByText('Test Testesen')).toBeTruthy(); + expect(queryByText('Varsler')).toBeNull(); + expect(queryByRole('switch')).toBeNull(); +}); + +it('falls back to the email when the account has no name', async () => { + const nameless: UserType = { ...testUser, first_name: '', last_name: '' }; + const { findAllByText } = await renderSettings({ user: nameless }); + + // Shown once as the title, not repeated as the email line + expect(await findAllByText('test@example.test')).toHaveLength(1); +}); + +it('logs out and returns to the membership screen', async () => { + const { findByText, queryByText } = await renderSettings(); + + await fireEvent.press(await findByText('Logg ut')); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' })); + expect(queryByText('Test Testesen')).toBeNull(); +}); + +it('opens the delete account screen', async () => { + const { findByText } = await renderSettings(); + + await fireEvent.press(await findByText('Slett bruker')); + + expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'UserDelete' }); +}); + +it('opens the privacy policy', async () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + const { findByText } = await renderSettings(); + + await fireEvent.press(await findByText('Personvern')); + + expect(openURL).toHaveBeenCalledWith('https://dusken.example.test/nb/personvern/'); +}); + +it('asks for permission and explains when notifications are denied', async () => { + const { findByRole, findByText } = await renderSettings(); + + await fireEvent(await findByRole('switch'), 'valueChange', true); + + expect(await findByText('Du må godta varsler i innstillinger for å motta påminnelser')).toBeTruthy(); + expect(Notifications.requestPermissionsAsync).toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); +}); + +it('schedules the expiry reminder when notifications are allowed', async () => { + (Notifications.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + const { findByRole, queryByText } = await renderSettings(); + + await fireEvent(await findByRole('switch'), 'valueChange', true); + + await waitFor(() => expect(Notifications.scheduleNotificationAsync).toHaveBeenCalled()); + // Stale reminders go before the new one + expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ title: 'Medlemskapet ditt har utløpt!' }), + trigger: expect.objectContaining({ type: 'date' }), + }), + ); + expect(queryByText('Du må godta varsler i innstillinger for å motta påminnelser')).toBeNull(); +}); + +it('cancels the reminder when the switch is turned off', async () => { + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + const { findByRole } = await renderSettings(); + + await fireEvent(await findByRole('switch'), 'valueChange', false); + + await waitFor(() => expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled()); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); +}); + +it('leaves for the membership screen when logged out', async () => { + await renderSettings({ token: null }); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' })); +}); diff --git a/src/modules/settings/Settings.tsx b/src/modules/settings/Settings.tsx new file mode 100644 index 0000000..6cfea42 --- /dev/null +++ b/src/modules/settings/Settings.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; + +import ListRow from '@/components/ListRow'; +import ListSection from '@/components/ListSection'; +import Toast from '@/components/Toast'; +import theme from '@/theme'; +import { displayName } from '@/utils'; + +import NotificationRow from './NotificationRow'; +import useSettings from './useSettings'; + +const Settings = () => { + const { user, onLogoutPress, onDeletePress, onPrivacyPress } = useSettings(); + const [toast, setToast] = useState(null); + + if (!user) { + return null; + } + + const name = displayName(user); + // Nothing to remind about without a current membership + const membershipEndDate = user.is_member ? user.last_membership?.end_date : undefined; + + return ( + + + + {name} + {name !== user.email && {user.email}} + + {membershipEndDate && ( + + + + )} + + + + + + + + + setToast(null)} + /> + + ); +}; + +export default Settings; + +const styles = StyleSheet.create({ + container: { + flex: 1, + ...theme.background, + }, + content: { + paddingBottom: 32, + }, + profile: { + alignItems: 'center', + paddingTop: 24, + paddingHorizontal: 16, + }, + name: { + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + }, + email: { + color: theme.colors.lightText, + fontSize: 16, + marginTop: 4, + textAlign: 'center', + }, +}); diff --git a/src/modules/settings/SettingsHeaderButton.tsx b/src/modules/settings/SettingsHeaderButton.tsx new file mode 100644 index 0000000..3b59635 --- /dev/null +++ b/src/modules/settings/SettingsHeaderButton.tsx @@ -0,0 +1,36 @@ +import Icon from '@react-native-vector-icons/feather/static'; +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; + +type SettingsHeaderButtonProps = { + /** Header tint from the navigator, so the icon matches the title */ + tintColor?: string; +}; + +const SettingsHeaderButton = ({ tintColor }: SettingsHeaderButtonProps) => { + const navigation = useNavigation(); + + return ( + navigation.navigate('UserStack', { screen: 'UserSettings' })} + style={({ pressed }) => [styles.button, pressed && styles.pressed]} + > + + + ); +}; + +const styles = StyleSheet.create({ + button: { + padding: 4, + }, + pressed: { + opacity: 0.7, + }, +}); + +export default SettingsHeaderButton; diff --git a/src/modules/settings/useSettings.tsx b/src/modules/settings/useSettings.tsx new file mode 100644 index 0000000..8723c0d --- /dev/null +++ b/src/modules/settings/useSettings.tsx @@ -0,0 +1,29 @@ +import { useNavigation } from '@react-navigation/native'; +import { useEffect } from 'react'; + +import { useAuth } from '@/auth/AuthContext'; +import { useUser } from '@/queries/useUser'; +import { openPrivacyPolicy } from '@/utils'; + +const useSettings = () => { + const navigation = useNavigation(); + const { isAuthenticated, signOut } = useAuth(); + const { user } = useUser(); + + // Not a screen for logged-out users + useEffect(() => { + if (!isAuthenticated) { + navigation.navigate('UserStack', { screen: 'Membership' }); + } + }, [isAuthenticated, navigation]); + + const onDeletePress = () => navigation.navigate('UserStack', { screen: 'UserDelete' }); + + return { + user, + onLogoutPress: signOut, + onDeletePress, + onPrivacyPress: openPrivacyPolicy, + }; +}; +export default useSettings; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index dccd032..ca8bb6e 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -12,6 +12,7 @@ export type UserStackParamList = { Login: undefined; UserRegister: undefined; UserDelete: undefined; + UserSettings: undefined; }; export type HomeTabParamList = { diff --git a/src/utils.ts b/src/utils.ts index f12276c..ec9e4e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,7 @@ +import { Linking } from 'react-native'; + +import type { UserType } from '@/types/data'; + // Sites tracked by our Umami instance. Subdomains (galtinn.neuf.no, www.*) match too. const APP_SOURCE_DOMAINS = ['neuf.no', 'studentersamfundet.no']; @@ -20,6 +24,17 @@ export function withAppSource(url: string): string { return parsed.toString(); } +export function openPrivacyPolicy() { + /** Opens the Dusken privacy policy in the browser */ + return Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL || '')); +} + +export function displayName(user: UserType): string { + /** Full name, or the email for accounts without one */ + const name = `${user.first_name} ${user.last_name}`.trim(); + return name || user.email; +} + export function snakeToCamelCase(val: string): string { return val.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); }