v0.2.2: active-session "time left" status notification (default on)
Some checks failed
build-apk / build (push) Has been cancelled

Starting a session posts an ongoing notification showing the expiry time + time
remaining, so you can glance at how long you have left without opening the app.
- Default ON; toggle in Account -> Notifications ("Active-session countdown").
- Kept fresh on app open / foreground (re-posts nearest-expiring active session,
  clears when none remain). Quiet channel, no banner/sound on refresh.
- Purely local (expo-notifications); no server/push.

Note: expo-notifications can't render a live per-second ticking countdown in the
background, so it shows the exact expiry time (always accurate) + remaining, and
refreshes on foreground. A true ticking chronometer would need notifee (native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 20:46:39 -07:00
parent 73079ca659
commit b9bd6bd1c6
7 changed files with 181 additions and 10 deletions

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking", "name": "BigBrainParking",
"slug": "bigbrainparking", "slug": "bigbrainparking",
"scheme": "bigbrainparking", "scheme": "bigbrainparking",
"version": "0.2.1", "version": "0.2.2",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.bigbrainparking", "package": "top.mowden.bigbrainparking",
"versionCode": 11, "versionCode": 12,
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",

View file

@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise<boolean> {
export async function setRemindersEnabled(on: boolean): Promise<void> { export async function setRemindersEnabled(on: boolean): Promise<void> {
await AsyncStorage.setItem(ENABLED_KEY, String(on)); await AsyncStorage.setItem(ENABLED_KEY, String(on));
} }
/**
* When true, an ongoing "time left" status notification is shown while a
* parking session is active, so you can glance at remaining time without
* opening the app. Default ON.
*/
const COUNTDOWN_KEY = 'ps_session_countdown_enabled';
export async function getCountdownEnabled(): Promise<boolean> {
const raw = await AsyncStorage.getItem(COUNTDOWN_KEY);
return raw == null ? true : raw === 'true';
}
export async function setCountdownEnabled(on: boolean): Promise<void> {
await AsyncStorage.setItem(COUNTDOWN_KEY, String(on));
}

View file

@ -24,6 +24,7 @@ import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import { useSessionStatusSync } from '@/notifications/sessionStatus';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
export type RootStackParamList = { export type RootStackParamList = {
@ -68,6 +69,8 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
}; };
function Tabs() { function Tabs() {
// Keep the ongoing "time left" status notification fresh (on open + foreground).
useSessionStatusSync();
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={({ route }) => ({ screenOptions={({ route }) => ({

View file

@ -33,14 +33,19 @@ export async function ensureNotificationPermission(): Promise<boolean> {
} }
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async (notification) => {
// The ongoing "time left" status refreshes on every foreground — keep it
// quiet (no banner/sound), just present in the shade.
const quiet = (notification.request.content.data as any)?.kind === 'session-status';
return {
// shouldShowAlert is the legacy field; Banner/List are the newer split. // shouldShowAlert is the legacy field; Banner/List are the newer split.
shouldShowAlert: true, shouldShowAlert: !quiet,
shouldShowBanner: true, shouldShowBanner: !quiet,
shouldShowList: true, shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: !quiet,
shouldSetBadge: false, shouldSetBadge: false,
}), };
},
}); });
export interface ScheduleReminderArgs { export interface ScheduleReminderArgs {

View file

@ -0,0 +1,119 @@
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import { ps } from '@/api/client';
import { getCountdownEnabled } from '@/features/notifications/reminderPrefs';
import { logLine } from '@/features/diagnostics/fileLogger';
/**
* An ongoing "time left" status notification shown while a parking session is
* active, so you can glance at remaining time without opening the app.
*
* It's a single notification (the soonest-expiring active session). expo-
* notifications can't render a live per-second ticking countdown in the
* background, so we show the exact expiry time (always accurate) plus the
* remaining time, and re-post it whenever the app comes to the foreground.
*/
const CHANNEL = 'session-status';
const NOTIF_ID = 'session-status';
async function ensureChannel(): Promise<void> {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync(CHANNEL, {
name: 'Active parking',
importance: Notifications.AndroidImportance.LOW, // quiet: no sound, no heads-up
showBadge: false,
});
}
/** Parse the API's "MM-DD-YYYY hh:mm AM/PM" time into a Date. */
export function parseApiTime(s?: string | null): Date | null {
if (!s) return null;
const m = String(s).match(/(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (!m) {
const d = new Date(s);
return Number.isNaN(+d) ? null : d;
}
let hr = parseInt(m[4], 10);
const pm = /pm/i.test(m[6]);
if (pm && hr !== 12) hr += 12;
if (!pm && hr === 12) hr = 0;
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
}
function fmtLeft(min: number): string {
if (min <= 0) return 'expired';
const h = Math.floor(min / 60);
const m = min % 60;
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
}
/** Post (or replace) the ongoing status notification for a session end time. */
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
if (!(await getCountdownEnabled())) return;
await ensureChannel();
const remainMin = Math.round((args.endTime.getTime() - Date.now()) / 60000);
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
await Notifications.scheduleNotificationAsync({
identifier: NOTIF_ID,
content: {
title: remainMin > 0 ? `Parking: ${fmtLeft(remainMin)} left` : 'Parking expired',
body: `${args.zoneName} · expires ${ends}`,
sticky: true, // ongoing — stays put, can't be swiped away
autoDismiss: false,
data: { kind: 'session-status' },
},
// Fire ~immediately; the DATE trigger lets us pin the Android channel.
trigger:
Platform.OS === 'android'
? {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(Date.now() + 400),
channelId: CHANNEL,
}
: null,
});
}
export async function clearSessionStatus(): Promise<void> {
await Notifications.dismissNotificationAsync(NOTIF_ID).catch(() => {});
await Notifications.cancelScheduledNotificationAsync(NOTIF_ID).catch(() => {});
}
/**
* Fetch active sessions and re-post the status for the soonest-expiring one,
* or clear it if there are none / the feature is off.
*/
export async function refreshSessionStatus(): Promise<void> {
if (!(await getCountdownEnabled())) {
await clearSessionStatus();
return;
}
try {
const res = await ps.getActiveParkingSessions();
const withEnd = (res.ParkingSession ?? [])
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
.filter((x) => x.end != null && x.end.getTime() > Date.now())
.sort((a, b) => a.end!.getTime() - b.end!.getTime());
if (withEnd.length === 0) {
await clearSessionStatus();
return;
}
const { s, end } = withEnd[0];
await showSessionStatus({ zoneName: s.ZoneName ?? s.Zone ?? 'Parking', endTime: end! });
} catch (e: any) {
logLine(`[STATUS] refresh failed: ${e?.serverMessage ?? e?.message ?? e}`);
}
}
/** Keep the status notification fresh: on mount and each time the app is foregrounded. */
export function useSessionStatusSync(): void {
useEffect(() => {
void refreshSessionStatus();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void refreshSessionStatus();
});
return () => sub.remove();
}, []);
}

View file

@ -3,6 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import { sendTestReminder } from '@/notifications/localReminders'; import { sendTestReminder } from '@/notifications/localReminders';
import { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
import { import {
DEFAULT_LEAD_MINUTES, DEFAULT_LEAD_MINUTES,
LEAD_STEP, LEAD_STEP,
@ -10,19 +11,23 @@ import {
MIN_LEAD_MINUTES, MIN_LEAD_MINUTES,
getReminderLeadMinutes, getReminderLeadMinutes,
getRemindersEnabled, getRemindersEnabled,
getCountdownEnabled,
setReminderLeadMinutes, setReminderLeadMinutes,
setRemindersEnabled, setRemindersEnabled,
setCountdownEnabled,
} from '@/features/notifications/reminderPrefs'; } from '@/features/notifications/reminderPrefs';
export function NotificationsScreen() { export function NotificationsScreen() {
const { colors } = useTheme(); const { colors } = useTheme();
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES); const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
const [countdown, setCountdown] = useState(true);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void getRemindersEnabled().then(setEnabled); void getRemindersEnabled().then(setEnabled);
void getReminderLeadMinutes().then(setLead); void getReminderLeadMinutes().then(setLead);
void getCountdownEnabled().then(setCountdown);
}, []), }, []),
); );
@ -31,6 +36,11 @@ export function NotificationsScreen() {
void setRemindersEnabled(v); void setRemindersEnabled(v);
}; };
const toggleCountdown = (v: boolean) => {
setCountdown(v);
void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
};
const bump = (delta: number) => { const bump = (delta: number) => {
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta)); const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
setLead(next); setLead(next);
@ -53,6 +63,21 @@ export function NotificationsScreen() {
</View> </View>
</View> </View>
<View style={[styles.card, { backgroundColor: colors.card, marginTop: 12 }]}>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={[styles.rowTitle, { color: colors.text }]}>
Active-session countdown
</Text>
<Text style={[styles.rowSub, { color: colors.subtext }]}>
An ongoing notification with the time left, so you can glance without opening
the app.
</Text>
</View>
<Switch value={countdown} onValueChange={toggleCountdown} />
</View>
</View>
<Text style={[styles.section, { color: colors.subtext }]}>Remind me</Text> <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.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
<View style={styles.stepper}> <View style={styles.stepper}>

View file

@ -15,6 +15,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client'; import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import { scheduleExpiryReminder } from '@/notifications/localReminders'; import { scheduleExpiryReminder } from '@/notifications/localReminders';
import { showSessionStatus } from '@/notifications/sessionStatus';
import { logLine } from '@/features/diagnostics/fileLogger'; import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator'; import type { RootStackParamList } from '@/navigation/RootNavigator';
import { import {
@ -281,6 +282,8 @@ export function StartSessionScreen() {
zoneName: zone.ZoneName ?? 'Parking', zoneName: zone.ZoneName ?? 'Parking',
endTime: end, endTime: end,
}); });
// Ongoing "time left" status notification (glanceable countdown).
await showSessionStatus({ zoneName: zone.ZoneName ?? 'Parking', endTime: end });
} }
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`); logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [ Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [