- Session-expiry reminders are purely on-device (AlarmManager); added an Android notification channel and a "Send a test reminder" button (Account > Notifications) to verify delivery without a real session - Strip all FCM/bridge talk from unifiedPush.ts + README; UnifiedPush(ntfy) is now an optional no-op stub for hypothetical future server-initiated messages only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
||
import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||
import { useFocusEffect } from '@react-navigation/native';
|
||
import { useTheme } from '@/theme/ThemeContext';
|
||
import { sendTestReminder } from '@/notifications/localReminders';
|
||
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>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.testBtn, { borderColor: colors.primary }]}
|
||
onPress={async () => {
|
||
const ok = await sendTestReminder(10);
|
||
Alert.alert(
|
||
ok ? 'Test scheduled' : 'Notifications blocked',
|
||
ok
|
||
? 'A test reminder will appear in ~10 seconds (you can background the app).'
|
||
: 'Allow notifications for BigBrainParking in system settings.',
|
||
);
|
||
}}
|
||
>
|
||
<Text style={{ color: colors.primary, fontWeight: '700' }}>Send a test reminder</Text>
|
||
</TouchableOpacity>
|
||
|
||
<Text style={[styles.footnote, { color: colors.subtext }]}>
|
||
Reminders fire entirely on-device (AlarmManager) — no server, no push, no Google
|
||
services. 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 },
|
||
testBtn: {
|
||
marginTop: 20,
|
||
borderWidth: 1.5,
|
||
borderRadius: 12,
|
||
padding: 14,
|
||
alignItems: 'center',
|
||
},
|
||
footnote: { fontSize: 12, marginTop: 16, lineHeight: 18 },
|
||
});
|