diff --git a/app/app.json b/app/app.json index 045ddb2..c14c856 100644 --- a/app/app.json +++ b/app/app.json @@ -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", diff --git a/app/src/features/notifications/reminderPrefs.ts b/app/src/features/notifications/reminderPrefs.ts index 2c8de3b..052eeff 100644 --- a/app/src/features/notifications/reminderPrefs.ts +++ b/app/src/features/notifications/reminderPrefs.ts @@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise { export async function setRemindersEnabled(on: boolean): Promise { 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 { + const raw = await AsyncStorage.getItem(COUNTDOWN_KEY); + return raw == null ? true : raw === 'true'; +} + +export async function setCountdownEnabled(on: boolean): Promise { + await AsyncStorage.setItem(COUNTDOWN_KEY, String(on)); +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index b0295a5..bc78967 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -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 = { }; function Tabs() { + // Keep the ongoing "time left" status notification fresh (on open + foreground). + useSessionStatusSync(); return ( ({ diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts index da4b8ef..2b39cd0 100644 --- a/app/src/notifications/localReminders.ts +++ b/app/src/notifications/localReminders.ts @@ -33,14 +33,19 @@ export async function ensureNotificationPermission(): Promise { } 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 { diff --git a/app/src/notifications/sessionStatus.ts b/app/src/notifications/sessionStatus.ts new file mode 100644 index 0000000..61458c7 --- /dev/null +++ b/app/src/notifications/sessionStatus.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + }, []); +} diff --git a/app/src/screens/NotificationsScreen.tsx b/app/src/screens/NotificationsScreen.tsx index 15557b4..d9cc7b0 100644 --- a/app/src/screens/NotificationsScreen.tsx +++ b/app/src/screens/NotificationsScreen.tsx @@ -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() { + + + + + Active-session countdown + + + An ongoing notification with the time left, so you can glance without opening + the app. + + + + + + Remind me diff --git a/app/src/screens/StartSessionScreen.tsx b/app/src/screens/StartSessionScreen.tsx index c834e2d..522b713 100644 --- a/app/src/screens/StartSessionScreen.tsx +++ b/app/src/screens/StartSessionScreen.tsx @@ -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}.`, [