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:
parent
a566f41d66
commit
9a5c9d445f
5 changed files with 173 additions and 3 deletions
37
app/src/features/notifications/reminderPrefs.ts
Normal file
37
app/src/features/notifications/reminderPrefs.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { AccountScreen } from '@/screens/AccountScreen';
|
|||
import { ProfileScreen } from '@/screens/ProfileScreen';
|
||||
import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
||||
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ export type RootStackParamList = {
|
|||
Profile: undefined;
|
||||
Vehicles: undefined;
|
||||
PaymentMethods: undefined;
|
||||
Notifications: undefined;
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
|
|
@ -117,6 +119,11 @@ export function RootNavigator() {
|
|||
component={PaymentMethodsScreen}
|
||||
options={{ title: 'Payment methods' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
options={{ title: 'Notifications' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import * as Notifications from 'expo-notifications';
|
||||
import {
|
||||
getReminderLeadMinutes,
|
||||
getRemindersEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
|
||||
/**
|
||||
* Session-expiry reminders as LOCAL scheduled notifications.
|
||||
|
|
@ -32,15 +36,20 @@ export interface ScheduleReminderArgs {
|
|||
zoneName: string;
|
||||
/** When the parking session ends. */
|
||||
endTime: Date;
|
||||
/** Fire this many minutes before end (default 10). */
|
||||
/** Override the user's configured lead time (minutes before end). */
|
||||
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(
|
||||
args: ScheduleReminderArgs,
|
||||
): 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);
|
||||
if (fireAt.getTime() <= Date.now()) return null; // already too late
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ export function AccountScreen() {
|
|||
label="Payment methods"
|
||||
onPress={() => navigation.navigate('PaymentMethods')}
|
||||
/>
|
||||
<Item
|
||||
icon="notifications"
|
||||
label="Notifications"
|
||||
onPress={() => navigation.navigate('Notifications')}
|
||||
/>
|
||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||
</View>
|
||||
|
||||
|
|
|
|||
112
app/src/screens/NotificationsScreen.tsx
Normal file
112
app/src/screens/NotificationsScreen.tsx
Normal 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 },
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue