Move account acctions and expiry reminder to a settings screen
This commit is contained in:
19 files changed
+588
-181
No files matched your search
@@ -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
@@ -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'];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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' }));
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -12,6 +12,7 @@ export type UserStackParamList = {
|
||||
Login: undefined;
|
||||
UserRegister: undefined;
|
||||
UserDelete: undefined;
|
||||
UserSettings: undefined;
|
||||
};
|
||||
|
||||
export type HomeTabParamList = {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user