Add Notifications preferences (session-expiry reminder lead time)

- New Notifications screen (Account > Notifications): enable/disable expiry
  reminders + a 5-minute-increment stepper (5–60 min), default 15 min
- localReminders.scheduleExpiryReminder now reads the persisted lead time + enabled
  flag, so it's ready to fire once the start-session flow is wired

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 11:33:17 -07:00
parent a566f41d66
commit 9a5c9d445f
5 changed files with 173 additions and 3 deletions

View file

@ -0,0 +1,37 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* How many minutes before a session's expiry to fire the local reminder.
* Configurable in Notifications preferences, in 5-minute increments.
*/
const KEY = 'ps_reminder_lead_minutes';
export const DEFAULT_LEAD_MINUTES = 15;
export const MIN_LEAD_MINUTES = 5;
export const MAX_LEAD_MINUTES = 60;
export const LEAD_STEP = 5;
/** When true, expiry reminders are enabled at all. */
const ENABLED_KEY = 'ps_reminder_enabled';
export async function getReminderLeadMinutes(): Promise<number> {
const raw = await AsyncStorage.getItem(KEY);
const n = raw != null ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) ? n : DEFAULT_LEAD_MINUTES;
}
export async function setReminderLeadMinutes(minutes: number): Promise<void> {
const clamped = Math.min(
MAX_LEAD_MINUTES,
Math.max(MIN_LEAD_MINUTES, Math.round(minutes / LEAD_STEP) * LEAD_STEP),
);
await AsyncStorage.setItem(KEY, String(clamped));
}
export async function getRemindersEnabled(): Promise<boolean> {
const raw = await AsyncStorage.getItem(ENABLED_KEY);
return raw == null ? true : raw === 'true';
}
export async function setRemindersEnabled(on: boolean): Promise<void> {
await AsyncStorage.setItem(ENABLED_KEY, String(on));
}

View file

@ -19,6 +19,7 @@ import { AccountScreen } from '@/screens/AccountScreen';
import { ProfileScreen } from '@/screens/ProfileScreen'; import { ProfileScreen } from '@/screens/ProfileScreen';
import { VehiclesScreen } from '@/screens/VehiclesScreen'; import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen'; import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import type { Zone } from 'parksmarter-client'; import type { Zone } from 'parksmarter-client';
@ -29,6 +30,7 @@ export type RootStackParamList = {
Profile: undefined; Profile: undefined;
Vehicles: undefined; Vehicles: undefined;
PaymentMethods: undefined; PaymentMethods: undefined;
Notifications: undefined;
}; };
export type TabParamList = { export type TabParamList = {
@ -117,6 +119,11 @@ export function RootNavigator() {
component={PaymentMethodsScreen} component={PaymentMethodsScreen}
options={{ title: 'Payment methods' }} options={{ title: 'Payment methods' }}
/> />
<Stack.Screen
name="Notifications"
component={NotificationsScreen}
options={{ title: 'Notifications' }}
/>
</Stack.Navigator> </Stack.Navigator>
) : ( ) : (
<LoginScreen /> <LoginScreen />

View file

@ -1,4 +1,8 @@
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import {
getReminderLeadMinutes,
getRemindersEnabled,
} from '@/features/notifications/reminderPrefs';
/** /**
* Session-expiry reminders as LOCAL scheduled notifications. * Session-expiry reminders as LOCAL scheduled notifications.
@ -32,15 +36,20 @@ export interface ScheduleReminderArgs {
zoneName: string; zoneName: string;
/** When the parking session ends. */ /** When the parking session ends. */
endTime: Date; endTime: Date;
/** Fire this many minutes before end (default 10). */ /** Override the user's configured lead time (minutes before end). */
leadMinutes?: number; leadMinutes?: number;
} }
/** Schedule an expiry reminder. Returns the notification id (for later cancel). */ /**
* Schedule an expiry reminder using the user's Notifications preferences
* (lead time + enabled). Returns the notification id, or null if disabled/late.
*/
export async function scheduleExpiryReminder( export async function scheduleExpiryReminder(
args: ScheduleReminderArgs, args: ScheduleReminderArgs,
): Promise<string | null> { ): Promise<string | null> {
const lead = (args.leadMinutes ?? 10) * 60 * 1000; if (!(await getRemindersEnabled())) return null;
const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
const lead = leadMinutes * 60 * 1000;
const fireAt = new Date(args.endTime.getTime() - lead); const fireAt = new Date(args.endTime.getTime() - lead);
if (fireAt.getTime() <= Date.now()) return null; // already too late if (fireAt.getTime() <= Date.now()) return null; // already too late

View file

@ -48,6 +48,11 @@ export function AccountScreen() {
label="Payment methods" label="Payment methods"
onPress={() => navigation.navigate('PaymentMethods')} onPress={() => navigation.navigate('PaymentMethods')}
/> />
<Item
icon="notifications"
label="Notifications"
onPress={() => navigation.navigate('Notifications')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} /> <Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View> </View>

View file

@ -0,0 +1,112 @@
import React, { useCallback, useState } from 'react';
import { StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '@/theme/ThemeContext';
import {
DEFAULT_LEAD_MINUTES,
LEAD_STEP,
MAX_LEAD_MINUTES,
MIN_LEAD_MINUTES,
getReminderLeadMinutes,
getRemindersEnabled,
setReminderLeadMinutes,
setRemindersEnabled,
} from '@/features/notifications/reminderPrefs';
export function NotificationsScreen() {
const { colors } = useTheme();
const [enabled, setEnabled] = useState(true);
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
useFocusEffect(
useCallback(() => {
void getRemindersEnabled().then(setEnabled);
void getReminderLeadMinutes().then(setLead);
}, []),
);
const toggle = (v: boolean) => {
setEnabled(v);
void setRemindersEnabled(v);
};
const bump = (delta: number) => {
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
setLead(next);
void setReminderLeadMinutes(next);
};
return (
<View style={[styles.container, { backgroundColor: colors.bg }]}>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={[styles.rowTitle, { color: colors.text }]}>
Session expiry reminders
</Text>
<Text style={[styles.rowSub, { color: colors.subtext }]}>
A local notification before your parking runs out.
</Text>
</View>
<Switch value={enabled} onValueChange={toggle} />
</View>
</View>
<Text style={[styles.section, { color: colors.subtext }]}>Remind me</Text>
<View style={[styles.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
<View style={styles.stepper}>
<TouchableOpacity
style={[styles.stepBtn, { borderColor: colors.border }]}
disabled={!enabled || lead <= MIN_LEAD_MINUTES}
onPress={() => bump(-LEAD_STEP)}
>
<Text style={[styles.stepText, { color: colors.text }]}></Text>
</TouchableOpacity>
<View style={styles.leadDisplay}>
<Text style={[styles.leadValue, { color: colors.text }]}>{lead}</Text>
<Text style={[styles.leadUnit, { color: colors.subtext }]}>
minutes before expiry
</Text>
</View>
<TouchableOpacity
style={[styles.stepBtn, { borderColor: colors.border }]}
disabled={!enabled || lead >= MAX_LEAD_MINUTES}
onPress={() => bump(LEAD_STEP)}
>
<Text style={[styles.stepText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
</View>
<Text style={[styles.footnote, { color: colors.subtext }]}>
Reminders are scheduled on-device (no Google services needed). Adjusts in{' '}
{LEAD_STEP}-minute steps, {MIN_LEAD_MINUTES}{MAX_LEAD_MINUTES} min.
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
card: { borderRadius: 12, padding: 16 },
row: { flexDirection: 'row', alignItems: 'center' },
rowTitle: { fontSize: 16, fontWeight: '600' },
rowSub: { fontSize: 13, marginTop: 2 },
section: { marginTop: 24, marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
stepBtn: {
width: 52,
height: 52,
borderRadius: 26,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stepText: { fontSize: 26, fontWeight: '700' },
leadDisplay: { alignItems: 'center' },
leadValue: { fontSize: 40, fontWeight: '800' },
leadUnit: { fontSize: 13, marginTop: 2 },
footnote: { fontSize: 12, marginTop: 20, lineHeight: 18 },
});