Move account acctions and expiry reminder to a settings screen

This commit is contained in:
ponas committed 2026-09-05 23:50:33 +02:00
1 parent f0d7952356
commit dfa68f50ec
19 files changed
+588 -181

No files matched your search

+52
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={token}>
<Navigation />
</AuthProvider>
</QueryClientProvider>,
);
};
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();
});
+37 -21
View File
@@ -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 = () => (
</EventStack.Navigator>
);
type HeaderRightType = NativeStackNavigationOptions['headerRight'];
const SettingsHeaderRight: HeaderRightType = ({ tintColor }) => <SettingsHeaderButton tintColor={tintColor} />;
const UserStack = createNativeStackNavigator<UserStackParamList>();
const UserStackScreen = () => (
<UserStack.Navigator initialRouteName="Membership" screenOptions={{ ...defaultNavigationStyle }}>
<UserStack.Screen name="Membership" component={Membership} options={{ title: 'Medlemskap' }} />
<UserStack.Screen name="Login" component={Login} options={{ title: 'Logg inn' }} />
<UserStack.Screen
name="UserRegister"
component={UserRegister}
options={{
title: 'Bli medlem',
}}
/>
<UserStack.Screen
name="UserDelete"
component={UserDelete}
options={{
title: 'Slett bruker',
}}
/>
</UserStack.Navigator>
);
const UserStackScreen = () => {
const { isAuthenticated } = useAuth();
return (
<UserStack.Navigator initialRouteName="Membership" screenOptions={{ ...defaultNavigationStyle }}>
<UserStack.Screen
name="Membership"
component={Membership}
// Settings only make sense with an account to manage
options={{ title: 'Medlemskap', headerRight: isAuthenticated ? SettingsHeaderRight : undefined }}
/>
<UserStack.Screen name="Login" component={Login} options={{ title: 'Logg inn' }} />
<UserStack.Screen
name="UserRegister"
component={UserRegister}
options={{
title: 'Bli medlem',
}}
/>
<UserStack.Screen
name="UserDelete"
component={UserDelete}
options={{
title: 'Slett bruker',
}}
/>
<UserStack.Screen name="UserSettings" component={Settings} options={{ title: 'Innstillinger' }} />
</UserStack.Navigator>
);
};
type TabBarIconType = BottomTabNavigationOptions['tabBarIcon'];
+76
View File
@@ -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 = (
<>
<Text style={[theme.text, styles.label, destructive && styles.destructive]}>{label}</Text>
{accessory}
{chevron && <Text style={styles.chevron}>›</Text>}
</>
);
if (!onPress) {
return (
<View style={styles.row} testID={testID}>
{content}
</View>
);
}
return (
<Pressable
accessibilityRole={accessibilityRole ?? 'button'}
accessibilityLabel={label}
onPress={onPress}
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
testID={testID}
>
{content}
</Pressable>
);
};
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;
+56
View File
@@ -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 (
<View style={styles.section}>
{title && <Text style={styles.title}>{title}</Text>}
<View style={styles.card}>
{rows.map((row, index) => (
// Rows are static per section, so the index is a stable key
<React.Fragment key={index}>
{index > 0 && <View style={styles.separator} />}
{row}
</React.Fragment>
))}
</View>
</View>
);
};
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;
+3 -6
View File
@@ -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 = () => (
<TouchableOpacity
style={[theme.background, styles.button]}
onPress={() => Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL || ''))}
>
<TouchableOpacity style={[theme.background, styles.button]} onPress={openPrivacyPolicy}>
<Text style={styles.linkText}>Personvern</Text>
</TouchableOpacity>
);
+2
View File
@@ -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();
});
+2
View File
@@ -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.
</Text>
<PrivacyButton />
</View>
</ScrollView>
);
+6 -6
View File
@@ -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(<Membership />, {
it('keeps account actions off the proof, behind the settings button', async () => {
const { findByText, queryByText } = await renderWithProviders(<Membership />, {
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();
});
+3 -21
View File
@@ -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 (
<>
<Proof
onLogoutPress={onLogoutPress}
fetchUser={fetchUser}
isRefetching={isRefetching}
onDeletePress={onDeletePress}
/>
<PrivacyButton />
</>
);
// Account actions and the privacy policy live behind the header's settings button
return <Proof fetchUser={fetchUser} isRefetching={isRefetching} />;
}
return (
+5 -8
View File
@@ -32,14 +32,11 @@ const renderProof = ({
fetchUser = jest.fn(),
membershipTypes,
}: { user?: UserType; fetchUser?: () => void; membershipTypes?: MembershipTypeOption[] | null } = {}) =>
renderWithProviders(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isRefetching={false} onDeletePress={jest.fn()} />,
{
initialToken: 'test-token',
initialUser: user,
initialMembershipTypes: membershipTypes,
},
);
renderWithProviders(<Proof fetchUser={fetchUser} isRefetching={false} />, {
initialToken: 'test-token',
initialUser: user,
initialMembershipTypes: membershipTypes,
});
beforeEach(() => {
jest.clearAllMocks();
+8 -111
View File
@@ -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 (
<View>
<Text style={[theme.text, styles.nameText]}>{name}</Text>
</View>
);
};
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 (
<View style={styles.notificationToggle}>
<Text style={[theme.text, styles.notificationText]}>Varsle meg når medlemskapet utløper</Text>
<Switch
value={hasPermission || false}
onValueChange={handleToggle}
trackColor={{ false: '#E0E0E0', true: theme.colors.neufOrange }}
thumbColor={hasPermission ? '#FFFFFF' : '#FFFFFF'}
ios_backgroundColor="#E0E0E0"
style={styles.switch}
/>
<Toast
message={toast?.message}
visible={toast?.visible || false}
position="bottom"
autoDismiss={3000}
onDismiss={() => setToast(null)}
/>
</View>
);
};
const MembershipName = ({ user }: { user: UserType }) => (
<View>
<Text style={[theme.text, styles.nameText]}>{displayName(user)}</Text>
</View>
);
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<ConfettiMethods>(null);
const burstRef = useRef<PIConfettiMethods>(null);
const confettiContainerRef = useRef<React.ComponentRef<typeof View>>(null);
@@ -341,17 +285,8 @@ const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofP
setMembershipTypeSlug={setMembershipTypeSlug}
/>
<MembershipValidTo user={user} />
<NotificationToggle user={user} />
</View>
</ScrollView>
<View style={styles.buttonContainer}>
<Button onPress={onLogoutPress} style={[theme.button, styles.logoutButton]} small>
<Text style={theme.buttonText}>Logg ut</Text>
</Button>
<Button onPress={onDeletePress} style={[theme.button, styles.deleteButton]} small>
<Text style={styles.whiteText}>Slett bruker</Text>
</Button>
</View>
</View>
<Toast
message={toast?.message}
@@ -409,17 +344,6 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 24,
},
notMember: {
backgroundColor: theme.colors.neufBlue,
flex: 1,
marginHorizontal: -16,
padding: 16,
},
notMemberText: {
color: theme.colors.neufWhite,
textAlign: 'center',
fontSize: 24,
},
validToValue: {
fontWeight: 'bold',
fontSize: 18,
@@ -431,16 +355,6 @@ const styles = StyleSheet.create({
alignSelf: 'center',
marginTop: 8,
},
logoutButton: {
marginBottom: 8,
},
deleteButton: {
backgroundColor: theme.colors.danger,
},
whiteText: {
color: theme.colors.neufWhite,
fontWeight: '500',
},
purchaseText: {
alignItems: 'center',
marginBottom: 20,
@@ -473,26 +387,9 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
marginTop: 30,
},
notificationToggle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 32,
},
notificationText: {
fontSize: 18,
},
switch: {
transform: [{ scale: 1.25 }],
},
container: {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
buttonContainer: {
paddingHorizontal: 16,
paddingBottom: 16,
backgroundColor: 'transparent',
},
});
+1 -8
View File
@@ -5,24 +5,17 @@ import { useUser } from '@/queries/useUser';
const useMembership = () => {
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,
};
+49
View File
@@ -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 (
<ListRow
label={LABEL}
accessory={
<Switch
accessibilityLabel={LABEL}
value={hasPermission || false}
onValueChange={handleToggle}
trackColor={{ false: '#E0E0E0', true: theme.colors.neufOrange }}
thumbColor="#FFFFFF"
ios_backgroundColor="#E0E0E0"
/>
}
/>
);
};
export default NotificationRow;
+125
View File
@@ -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(<Settings />, { 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' }));
});
+82
View File
@@ -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<string | null>(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 (
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.profile}>
<Text style={[theme.text, styles.name]}>{name}</Text>
{name !== user.email && <Text style={styles.email}>{user.email}</Text>}
</View>
{membershipEndDate && (
<ListSection title="Varsler">
<NotificationRow expiryDate={membershipEndDate} showToast={setToast} />
</ListSection>
)}
<ListSection>
<ListRow label="Personvern" chevron accessibilityRole="link" onPress={onPrivacyPress} />
<ListRow label="Logg ut" onPress={onLogoutPress} />
</ListSection>
<ListSection>
<ListRow label="Slett bruker" destructive onPress={onDeletePress} />
</ListSection>
</ScrollView>
<Toast
message={toast ?? undefined}
visible={toast !== null}
position="bottom"
autoDismiss={3000}
onDismiss={() => setToast(null)}
/>
</View>
);
};
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',
},
});
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel="Innstillinger"
hitSlop={8}
onPress={() => navigation.navigate('UserStack', { screen: 'UserSettings' })}
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
>
<Icon name="settings" size={24} color={tintColor} />
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
padding: 4,
},
pressed: {
opacity: 0.7,
},
});
export default SettingsHeaderButton;
+29
View File
@@ -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;
+1
View File
@@ -12,6 +12,7 @@ export type UserStackParamList = {
Login: undefined;
UserRegister: undefined;
UserDelete: undefined;
UserSettings: undefined;
};
export type HomeTabParamList = {
+15
View File
@@ -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());
}