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 { 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 />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
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