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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => ({
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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 { 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}>
|
||||||
|
|
|
||||||
|
|
@ -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}.`, [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue