diff --git a/app/src/features/notifications/reminderPrefs.ts b/app/src/features/notifications/reminderPrefs.ts new file mode 100644 index 0000000..2c8de3b --- /dev/null +++ b/app/src/features/notifications/reminderPrefs.ts @@ -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 { + 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 { + 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 { + const raw = await AsyncStorage.getItem(ENABLED_KEY); + return raw == null ? true : raw === 'true'; +} + +export async function setRemindersEnabled(on: boolean): Promise { + await AsyncStorage.setItem(ENABLED_KEY, String(on)); +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index e44dcfd..7bce428 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -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' }} /> + ) : ( diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts index 5c54a2f..4450ab6 100644 --- a/app/src/notifications/localReminders.ts +++ b/app/src/notifications/localReminders.ts @@ -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 { - 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 diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx index 16aa1c6..b0e43be 100644 --- a/app/src/screens/AccountScreen.tsx +++ b/app/src/screens/AccountScreen.tsx @@ -48,6 +48,11 @@ export function AccountScreen() { label="Payment methods" onPress={() => navigation.navigate('PaymentMethods')} /> + navigation.navigate('Notifications')} + /> navigation.navigate('About')} /> diff --git a/app/src/screens/NotificationsScreen.tsx b/app/src/screens/NotificationsScreen.tsx new file mode 100644 index 0000000..3333497 --- /dev/null +++ b/app/src/screens/NotificationsScreen.tsx @@ -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 ( + + + + + + Session expiry reminders + + + A local notification before your parking runs out. + + + + + + + Remind me + + + bump(-LEAD_STEP)} + > + + + + + {lead} + + minutes before expiry + + + + = MAX_LEAD_MINUTES} + onPress={() => bump(LEAD_STEP)} + > + + + + + + + + Reminders are scheduled on-device (no Google services needed). Adjusts in{' '} + {LEAD_STEP}-minute steps, {MIN_LEAD_MINUTES}–{MAX_LEAD_MINUTES} min. + + + ); +} + +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 }, +});