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());
}