Add About screen with 7-tap joelangel easter egg; fix deps
- About screen surfaces getAbout() content; tapping the title 7x reveals assets/joelangel.png (placeholder committed; replace with the real image) - Correct UnifiedPush package to react-native-unifiedpush-connector (lazy-required) - Fix expo-notifications NotificationBehavior (shouldShowAlert) - Reachable via an ⓘ header button on the main tabs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
170f2fa63c
commit
6b19fc79dd
10 changed files with 9699 additions and 17 deletions
|
|
@ -26,12 +26,7 @@
|
||||||
"locationWhenInUsePermission": "Show nearby parking meters on the map."
|
"locationWhenInUsePermission": "Show nearby parking meters on the map."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"expo-notifications",
|
"expo-notifications",
|
||||||
{
|
|
||||||
"icon": "./assets/notification-icon.png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-vision-camera",
|
"react-native-vision-camera",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
BIN
app/assets/joelangel.png
Normal file
BIN
app/assets/joelangel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
|
|
@ -7,7 +7,8 @@
|
||||||
"start": "expo start --dev-client",
|
"start": "expo start --dev-client",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"prebuild": "expo prebuild --platform android --clean",
|
"prebuild": "expo prebuild --platform android --clean",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"ios": "expo run:ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parksmarter-client": "*",
|
"parksmarter-client": "*",
|
||||||
|
|
@ -27,7 +28,7 @@
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@maplibre/maplibre-react-native": "^10.0.0",
|
"@maplibre/maplibre-react-native": "^10.0.0",
|
||||||
"react-native-vision-camera": "^4.6.0",
|
"react-native-vision-camera": "^4.6.0",
|
||||||
"react-native-unifiedpush": "^2.0.0",
|
"react-native-unifiedpush-connector": "^0.4.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-native": "0.79.0"
|
"react-native": "0.79.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import {
|
||||||
|
createNativeStackNavigator,
|
||||||
|
type NativeStackNavigationProp,
|
||||||
|
} from '@react-navigation/native-stack';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { LoginScreen } from '@/screens/LoginScreen';
|
import { LoginScreen } from '@/screens/LoginScreen';
|
||||||
|
|
@ -10,11 +13,13 @@ import { ScanScreen } from '@/screens/ScanScreen';
|
||||||
import { FavoritesScreen } from '@/screens/FavoritesScreen';
|
import { FavoritesScreen } from '@/screens/FavoritesScreen';
|
||||||
import { SessionsScreen } from '@/screens/SessionsScreen';
|
import { SessionsScreen } from '@/screens/SessionsScreen';
|
||||||
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
|
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
|
||||||
|
import { AboutScreen } from '@/screens/AboutScreen';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Tabs: undefined;
|
Tabs: undefined;
|
||||||
MeterDetail: { zone: Zone };
|
MeterDetail: { zone: Zone };
|
||||||
|
About: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabParamList = {
|
export type TabParamList = {
|
||||||
|
|
@ -27,9 +32,20 @@ export type TabParamList = {
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<TabParamList>();
|
const Tab = createBottomTabNavigator<TabParamList>();
|
||||||
|
|
||||||
|
function AboutButton() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('About')} style={{ paddingHorizontal: 12 }}>
|
||||||
|
<Text style={{ fontSize: 18 }}>ⓘ</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Tabs() {
|
function Tabs() {
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator screenOptions={{ headerShown: true }}>
|
<Tab.Navigator
|
||||||
|
screenOptions={{ headerShown: true, headerRight: () => <AboutButton /> }}
|
||||||
|
>
|
||||||
<Tab.Screen name="Map" component={MapScreen} />
|
<Tab.Screen name="Map" component={MapScreen} />
|
||||||
<Tab.Screen name="Scan" component={ScanScreen} />
|
<Tab.Screen name="Scan" component={ScanScreen} />
|
||||||
<Tab.Screen name="Favorites" component={FavoritesScreen} />
|
<Tab.Screen name="Favorites" component={FavoritesScreen} />
|
||||||
|
|
@ -59,6 +75,7 @@ export function RootNavigator() {
|
||||||
component={MeterDetailScreen}
|
component={MeterDetailScreen}
|
||||||
options={{ title: 'Meter' }}
|
options={{ title: 'Meter' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
|
// shouldShowAlert is the legacy field; Banner/List are the newer split.
|
||||||
|
shouldShowAlert: true,
|
||||||
shouldShowBanner: true,
|
shouldShowBanner: true,
|
||||||
shouldShowList: true,
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,27 @@ import { ps } from '@/api/client';
|
||||||
* the app is ready to be a push target once the bridge (or a self-hosted relay
|
* the app is ready to be a push target once the bridge (or a self-hosted relay
|
||||||
* that we register with `PUT /api/Device`) is in place.
|
* that we register with `PUT /api/Device`) is in place.
|
||||||
*
|
*
|
||||||
* The `react-native-unifiedpush` API surface varies by version; treat the calls
|
* The `react-native-unifiedpush-connector` API surface varies by version; treat the
|
||||||
* below as the integration point to confirm against the installed version.
|
* calls below as the integration point to confirm against the installed version.
|
||||||
|
* It's lazy-required so the app runs fine even before push is fully wired.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const UnifiedPush = require('react-native-unifiedpush');
|
|
||||||
|
|
||||||
const ENDPOINT_KEY = 'ps_unifiedpush_endpoint';
|
const ENDPOINT_KEY = 'ps_unifiedpush_endpoint';
|
||||||
const INSTANCE = 'default';
|
const INSTANCE = 'default';
|
||||||
|
|
||||||
|
function loadUnifiedPush(): any | null {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
return require('react-native-unifiedpush-connector');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Kick off distributor discovery + registration (call after login). */
|
/** Kick off distributor discovery + registration (call after login). */
|
||||||
export async function registerUnifiedPush(): Promise<void> {
|
export async function registerUnifiedPush(): Promise<void> {
|
||||||
|
const UnifiedPush = loadUnifiedPush();
|
||||||
|
if (!UnifiedPush) return;
|
||||||
const distributors: string[] = await UnifiedPush.getDistributors();
|
const distributors: string[] = await UnifiedPush.getDistributors();
|
||||||
if (!distributors.length) {
|
if (!distributors.length) {
|
||||||
// No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work.
|
// No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work.
|
||||||
|
|
|
||||||
72
app/src/screens/AboutScreen.tsx
Normal file
72
app/src/screens/AboutScreen.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { ps } from '@/api/client';
|
||||||
|
|
||||||
|
const TAPS_TO_UNLOCK = 7;
|
||||||
|
|
||||||
|
export function AboutScreen() {
|
||||||
|
const [about, setAbout] = useState<string | null>(null);
|
||||||
|
const [showJoel, setShowJoel] = useState(false);
|
||||||
|
const taps = useRef(0);
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ps.getAbout()
|
||||||
|
.then((r) => setAbout(r.Value ?? ''))
|
||||||
|
.catch(() => setAbout(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Tap the title 7 times (within a rolling window) to summon Joel.
|
||||||
|
const onTitleTap = () => {
|
||||||
|
taps.current += 1;
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
resetTimer.current = setTimeout(() => (taps.current = 0), 1500);
|
||||||
|
if (taps.current >= TAPS_TO_UNLOCK) {
|
||||||
|
taps.current = 0;
|
||||||
|
setShowJoel(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Pressable onPress={onTitleTap}>
|
||||||
|
<Text style={styles.title}>About BigBrainParking</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.body}>{about ?? 'Loading…'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Modal visible={showJoel} transparent animationType="fade" onRequestClose={() => setShowJoel(false)}>
|
||||||
|
<TouchableOpacity style={styles.joelBackdrop} activeOpacity={1} onPress={() => setShowJoel(false)}>
|
||||||
|
<Image source={require('../../assets/joelangel.png')} style={styles.joel} resizeMode="contain" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { padding: 20 },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
|
||||||
|
body: { fontSize: 15, lineHeight: 22, color: '#333' },
|
||||||
|
meta: { fontSize: 12, color: '#999', marginTop: 24 },
|
||||||
|
joelBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
joel: { width: '92%', height: '80%' },
|
||||||
|
});
|
||||||
BIN
joel.jpeg
Normal file
BIN
joel.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
joelangel.png
Normal file
BIN
joelangel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
9586
package-lock.json
generated
Normal file
9586
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue