All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
import React from 'react';
|
|
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
|
import {
|
|
createNativeStackNavigator,
|
|
type NativeStackNavigationProp,
|
|
} from '@react-navigation/native-stack';
|
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { LoginScreen } from '@/screens/LoginScreen';
|
|
import { MapScreen } from '@/screens/MapScreen';
|
|
import { ScanScreen } from '@/screens/ScanScreen';
|
|
import { FavoritesScreen } from '@/screens/FavoritesScreen';
|
|
import { SessionsScreen } from '@/screens/SessionsScreen';
|
|
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
|
|
import { AboutScreen } from '@/screens/AboutScreen';
|
|
import { AccountScreen } from '@/screens/AccountScreen';
|
|
import { ProfileScreen } from '@/screens/ProfileScreen';
|
|
import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
|
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
|
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
|
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
|
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
|
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
|
import { AdminScreen } from '@/screens/AdminScreen';
|
|
import { CityAreaScreen } from '@/screens/CityAreaScreen';
|
|
import { MapAlignScreen } from '@/screens/MapAlignScreen';
|
|
import { useTheme } from '@/theme/ThemeContext';
|
|
import { useActiveParkingSync } from '@/features/session/activeParking';
|
|
import type { ParkingArea } from '@/api/parkingAreas';
|
|
import type { ParkedSpot } from '@/features/session/activeParkingStore';
|
|
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
|
|
|
export type RootStackParamList = {
|
|
Tabs: undefined;
|
|
MeterDetail: { zone: Zone };
|
|
StartSession: { zone: Zone };
|
|
/** An area from the city's printed parking map — no ParkSmarter zone involved. */
|
|
CityArea: { area: ParkingArea; spot?: ParkedSpot };
|
|
MapAlign: undefined;
|
|
SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' };
|
|
About: undefined;
|
|
Profile: undefined;
|
|
Vehicles: undefined;
|
|
PaymentMethods: undefined;
|
|
Notifications: undefined;
|
|
Diagnostics: undefined;
|
|
Admin: undefined;
|
|
};
|
|
|
|
export type TabParamList = {
|
|
Map: undefined;
|
|
Scan: undefined;
|
|
Favorites: undefined;
|
|
Sessions: undefined;
|
|
Account: undefined;
|
|
};
|
|
|
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
|
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>
|
|
);
|
|
}
|
|
|
|
const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
|
Map: 'map',
|
|
Scan: 'qr-code',
|
|
Favorites: 'star',
|
|
Sessions: 'time',
|
|
Account: 'person',
|
|
};
|
|
|
|
function Tabs() {
|
|
// Keep the ongoing countdown in sync (on open + every foreground) and apply any
|
|
// End/Extend the user pressed on the notification while the app was away.
|
|
useActiveParkingSync();
|
|
return (
|
|
<Tab.Navigator
|
|
screenOptions={({ route }) => ({
|
|
headerShown: true,
|
|
headerRight: () => <AboutButton />,
|
|
tabBarActiveTintColor: '#1e6f5c',
|
|
tabBarIcon: ({ color, size }) => (
|
|
<Ionicons name={TAB_ICONS[route.name]} size={size} color={color} />
|
|
),
|
|
})}
|
|
>
|
|
<Tab.Screen name="Map" component={MapScreen} />
|
|
<Tab.Screen name="Scan" component={ScanScreen} />
|
|
<Tab.Screen name="Favorites" component={FavoritesScreen} />
|
|
<Tab.Screen name="Sessions" component={SessionsScreen} />
|
|
<Tab.Screen name="Account" component={AccountScreen} />
|
|
</Tab.Navigator>
|
|
);
|
|
}
|
|
|
|
export function RootNavigator() {
|
|
const { status } = useAuth();
|
|
const { navTheme } = useTheme();
|
|
|
|
if (status === 'loading') {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: navTheme.colors.background,
|
|
}}
|
|
>
|
|
<ActivityIndicator size="large" color={navTheme.colors.primary} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<NavigationContainer theme={navTheme}>
|
|
{status === 'signedIn' || status === 'anonymous' ? (
|
|
<Stack.Navigator>
|
|
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
|
<Stack.Screen
|
|
name="MeterDetail"
|
|
component={MeterDetailScreen}
|
|
options={{ title: 'Meter' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="StartSession"
|
|
component={StartSessionScreen}
|
|
options={{ title: 'Start session' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="SessionDetail"
|
|
component={SessionDetailScreen}
|
|
options={{ title: 'Session' }}
|
|
/>
|
|
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
|
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
|
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
|
<Stack.Screen
|
|
name="PaymentMethods"
|
|
component={PaymentMethodsScreen}
|
|
options={{ title: 'Payment methods' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="Notifications"
|
|
component={NotificationsScreen}
|
|
options={{ title: 'Notifications' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="Diagnostics"
|
|
component={DiagnosticsScreen}
|
|
options={{ title: 'Diagnostics' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="CityArea"
|
|
component={CityAreaScreen}
|
|
options={{ title: 'Parking area' }}
|
|
/>
|
|
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
|
|
<Stack.Screen
|
|
name="MapAlign"
|
|
component={MapAlignScreen}
|
|
options={{ title: 'Align city map' }}
|
|
/>
|
|
</Stack.Navigator>
|
|
) : (
|
|
<LoginScreen />
|
|
)}
|
|
</NavigationContainer>
|
|
);
|
|
}
|