v0.2.2: active-session "time left" status notification (default on)
Some checks failed
build-apk / build (push) Has been cancelled
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:
parent
73079ca659
commit
b9bd6bd1c6
7 changed files with 181 additions and 10 deletions
|
|
@ -3,14 +3,14 @@
|
|||
"name": "BigBrainParking",
|
||||
"slug": "bigbrainparking",
|
||||
"scheme": "bigbrainparking",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"icon": "./assets/icon.png",
|
||||
"android": {
|
||||
"package": "top.mowden.bigbrainparking",
|
||||
"versionCode": 11,
|
||||
"versionCode": 12,
|
||||
"edgeToEdgeEnabled": true,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
|
|
|
|||
|
|
@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise<boolean> {
|
|||
export async function setRemindersEnabled(on: boolean): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
|||
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
||||
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
||||
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
||||
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -68,6 +69,8 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
|||
};
|
||||
|
||||
function Tabs() {
|
||||
// Keep the ongoing "time left" status notification fresh (on open + foreground).
|
||||
useSessionStatusSync();
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
|
|
|
|||
|
|
@ -33,14 +33,19 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
|||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
// shouldShowAlert is the legacy field; Banner/List are the newer split.
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
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: !quiet,
|
||||
shouldShowBanner: !quiet,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: !quiet,
|
||||
shouldSetBadge: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export interface ScheduleReminderArgs {
|
||||
|
|
|
|||
119
app/src/notifications/sessionStatus.ts
Normal file
119
app/src/notifications/sessionStatus.ts
Normal 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();
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
|
|||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { sendTestReminder } from '@/notifications/localReminders';
|
||||
import { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
|
||||
import {
|
||||
DEFAULT_LEAD_MINUTES,
|
||||
LEAD_STEP,
|
||||
|
|
@ -10,19 +11,23 @@ import {
|
|||
MIN_LEAD_MINUTES,
|
||||
getReminderLeadMinutes,
|
||||
getRemindersEnabled,
|
||||
getCountdownEnabled,
|
||||
setReminderLeadMinutes,
|
||||
setRemindersEnabled,
|
||||
setCountdownEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
|
||||
const [countdown, setCountdown] = useState(true);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void getRemindersEnabled().then(setEnabled);
|
||||
void getReminderLeadMinutes().then(setLead);
|
||||
void getCountdownEnabled().then(setCountdown);
|
||||
}, []),
|
||||
);
|
||||
|
||||
|
|
@ -31,6 +36,11 @@ export function NotificationsScreen() {
|
|||
void setRemindersEnabled(v);
|
||||
};
|
||||
|
||||
const toggleCountdown = (v: boolean) => {
|
||||
setCountdown(v);
|
||||
void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
|
||||
};
|
||||
|
||||
const bump = (delta: number) => {
|
||||
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
|
||||
setLead(next);
|
||||
|
|
@ -53,6 +63,21 @@ export function NotificationsScreen() {
|
|||
</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>
|
||||
<View style={[styles.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
|
||||
<View style={styles.stepper}>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|||
import { ps } from '@/api/client';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||
import { showSessionStatus } from '@/notifications/sessionStatus';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import {
|
||||
|
|
@ -281,6 +282,8 @@ export function StartSessionScreen() {
|
|||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
endTime: end,
|
||||
});
|
||||
// Ongoing "time left" status notification (glanceable countdown).
|
||||
await showSessionStatus({ zoneName: zone.ZoneName ?? 'Parking', endTime: end });
|
||||
}
|
||||
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue