Mobile UX fixes
22
.gitlab-ci.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
stages:
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
deploy_prod:
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script:
|
||||||
|
- echo ">>> Pulling latest code"
|
||||||
|
- git pull origin main
|
||||||
|
|
||||||
|
- echo ">>> Pulling updated images"
|
||||||
|
- docker compose pull
|
||||||
|
|
||||||
|
- echo ">>> Rebuilding local images if needed"
|
||||||
|
- docker compose build
|
||||||
|
|
||||||
|
- echo ">>> Starting/Updating services"
|
||||||
|
- docker compose up -d
|
||||||
|
|
||||||
|
- echo ">>> Removing old containers"
|
||||||
|
- docker image prune -f
|
||||||
BIN
.playwright-mcp/menu-bottom-200.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/menu-debug.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/menu-final-position.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/menu-portal-test.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/quick-action-menu-fixed.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/quick-action-menu-position.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.playwright-mcp/speeddial-check.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
@@ -10,11 +10,6 @@ import { useIsAuthInitialized } from './core/auth/auth-gate';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
|
|
||||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
|
||||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
|
||||||
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
|
||||||
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
|
||||||
import { md3Theme } from './shared-minimal/theme/md3Theme';
|
import { md3Theme } from './shared-minimal/theme/md3Theme';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { UnitsProvider } from './core/units/UnitsContext';
|
import { UnitsProvider } from './core/units/UnitsContext';
|
||||||
@@ -43,7 +38,9 @@ const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminU
|
|||||||
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
|
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
|
||||||
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
|
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
|
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
|
||||||
|
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
|
||||||
|
import { HamburgerDrawer } from './shared-minimal/components/mobile/HamburgerDrawer';
|
||||||
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
|
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
|
||||||
import { RouteSuspense } from './components/SuspenseWrappers';
|
import { RouteSuspense } from './components/SuspenseWrappers';
|
||||||
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
||||||
@@ -362,15 +359,29 @@ function App() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [goBack, canGoBack, mobileMode]);
|
}, [goBack, canGoBack, mobileMode]);
|
||||||
|
|
||||||
// Mobile navigation items
|
// Menu state
|
||||||
const mobileNavItems: NavigationItem[] = [
|
const [hamburgerOpen, setHamburgerOpen] = useState(false);
|
||||||
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
|
|
||||||
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
|
// Quick action handler
|
||||||
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
|
const handleQuickAction = useCallback((action: QuickAction) => {
|
||||||
{ key: "Stations", label: "Stations", icon: <LocalGasStationRoundedIcon /> },
|
switch (action) {
|
||||||
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
|
case 'log-fuel':
|
||||||
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
|
navigateToScreen('Log Fuel', { source: 'quick-action' });
|
||||||
];
|
break;
|
||||||
|
case 'add-vehicle':
|
||||||
|
setShowAddVehicle(true);
|
||||||
|
navigateToScreen('Vehicles', { source: 'quick-action' });
|
||||||
|
navigateToVehicleSubScreen('add', undefined, { source: 'quick-action' });
|
||||||
|
break;
|
||||||
|
case 'add-document':
|
||||||
|
navigateToScreen('Documents', { source: 'quick-action' });
|
||||||
|
break;
|
||||||
|
case 'add-maintenance':
|
||||||
|
// Navigate to maintenance or open form (future implementation)
|
||||||
|
navigateToScreen('Vehicles', { source: 'quick-action' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [navigateToScreen, navigateToVehicleSubScreen]);
|
||||||
|
|
||||||
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
|
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
|
||||||
|
|
||||||
@@ -689,9 +700,8 @@ function App() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<BottomNavigation
|
<BottomNavigation
|
||||||
items={mobileNavItems}
|
activeScreen={activeScreen}
|
||||||
activeItem={activeScreen}
|
onNavigate={(screen) => startTransition(() => {
|
||||||
onItemSelect={(screen) => startTransition(() => {
|
|
||||||
// Prefetch data for the target screen
|
// Prefetch data for the target screen
|
||||||
prefetchForNavigation(screen);
|
prefetchForNavigation(screen);
|
||||||
|
|
||||||
@@ -704,8 +714,29 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigate after state cleanup
|
// Navigate after state cleanup
|
||||||
navigateToScreen(screen as any, { source: 'bottom-navigation' });
|
navigateToScreen(screen, { source: 'bottom-navigation' });
|
||||||
})}
|
})}
|
||||||
|
onQuickAction={handleQuickAction}
|
||||||
|
onHamburgerPress={() => setHamburgerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HamburgerDrawer
|
||||||
|
open={hamburgerOpen}
|
||||||
|
onClose={() => setHamburgerOpen(false)}
|
||||||
|
onNavigate={(screen) => {
|
||||||
|
setHamburgerOpen(false);
|
||||||
|
startTransition(() => {
|
||||||
|
prefetchForNavigation(screen);
|
||||||
|
if (screen !== 'Vehicles') {
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
}
|
||||||
|
if (screen !== 'Vehicles' || vehicleSubScreen !== 'add') {
|
||||||
|
setShowAddVehicle(false);
|
||||||
|
}
|
||||||
|
navigateToScreen(screen, { source: 'hamburger-menu' });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
activeScreen={activeScreen}
|
||||||
/>
|
/>
|
||||||
</UnitsProvider>
|
</UnitsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useTransition, useEffect } from 'react';
|
import React, { useTransition, useEffect } from 'react';
|
||||||
import { Box, Typography, Grid, Fab } from '@mui/material';
|
import { Box, Typography, Grid, Button } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { useVehicles } from '../hooks/useVehicles';
|
import { useVehicles } from '../hooks/useVehicles';
|
||||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||||
@@ -82,38 +82,33 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
|||||||
|
|
||||||
if (!optimisticVehicles.length) {
|
if (!optimisticVehicles.length) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
<Box sx={{ pb: 10 }}>
|
||||||
<Section title="Vehicles">
|
<Section title="Vehicles">
|
||||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||||
No vehicles added yet
|
No vehicles added yet
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
|
||||||
Add your first vehicle to get started
|
Add your first vehicle to get started
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => onAddVehicle?.()}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
Add Vehicle
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Floating Action Button */}
|
|
||||||
<Fab
|
|
||||||
color="primary"
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 80, // Above bottom navigation
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1000
|
|
||||||
}}
|
|
||||||
onClick={() => onAddVehicle?.()}
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</Fab>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileVehiclesSuspense>
|
<MobileVehiclesSuspense>
|
||||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
<Box sx={{ pb: 10 }}>
|
||||||
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
|
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{filteredVehicles.map((vehicle) => (
|
{filteredVehicles.map((vehicle) => (
|
||||||
@@ -126,20 +121,6 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Floating Action Button */}
|
|
||||||
<Fab
|
|
||||||
color="primary"
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 80, // Above bottom navigation
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1000
|
|
||||||
}}
|
|
||||||
onClick={() => onAddVehicle?.()}
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</Fab>
|
|
||||||
</Box>
|
</Box>
|
||||||
</MobileVehiclesSuspense>
|
</MobileVehiclesSuspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,58 +1,314 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Bottom navigation component with Material Design 3
|
* @ai-summary Bottom navigation component with center FAB and hamburger menu
|
||||||
|
* @ai-context Layout: Dashboard | Vehicles | + FAB | Stations | Hamburger
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BottomNavigation as MuiBottomNavigation, BottomNavigationAction } from '@mui/material';
|
import { Box, IconButton, Typography, useTheme, SpeedDial, SpeedDialAction, Backdrop, SpeedDialIcon } from '@mui/material';
|
||||||
|
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
|
||||||
|
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
|
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||||
|
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { MobileScreen } from '../../../core/store/navigation';
|
||||||
|
import { quickActions, QuickAction } from './quickActions';
|
||||||
|
|
||||||
export interface NavigationItem {
|
interface BottomNavigationProps {
|
||||||
key: string;
|
activeScreen: MobileScreen;
|
||||||
|
onNavigate: (screen: MobileScreen) => void;
|
||||||
|
onQuickAction: (action: QuickAction) => void;
|
||||||
|
onHamburgerPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
screen: MobileScreen;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BottomNavigationProps {
|
const leftNavItems: NavItem[] = [
|
||||||
items: NavigationItem[];
|
{ screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },
|
||||||
activeItem: string;
|
{ screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
|
||||||
onItemSelect: (key: string) => void;
|
];
|
||||||
}
|
|
||||||
|
const rightNavItems: NavItem[] = [
|
||||||
|
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
||||||
items,
|
activeScreen,
|
||||||
activeItem,
|
onNavigate,
|
||||||
onItemSelect
|
onQuickAction,
|
||||||
|
onHamburgerPress
|
||||||
}) => {
|
}) => {
|
||||||
const activeIndex = items.findIndex(item => item.key === activeItem);
|
const theme = useTheme();
|
||||||
|
const [speedDialOpen, setSpeedDialOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const closeSpeedDial = () => setSpeedDialOpen(false);
|
||||||
|
const openSpeedDial = () => setSpeedDialOpen(true);
|
||||||
|
|
||||||
|
const NavButton: React.FC<{
|
||||||
|
item: NavItem;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}> = ({ item, isActive, onClick }) => (
|
||||||
|
<Box
|
||||||
|
onClick={() => {
|
||||||
|
closeSpeedDial();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
py: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: isActive ? theme.palette.primary.main : theme.palette.text.secondary,
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
minWidth: 64,
|
||||||
|
'&:active': {
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: 'inherit',
|
||||||
|
p: 0.5
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconButton>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
color: 'inherit',
|
||||||
|
mt: 0.25
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MuiBottomNavigation
|
<Box
|
||||||
showLabels
|
|
||||||
value={activeIndex}
|
|
||||||
onChange={(_, newValue) => onItemSelect(items[newValue].key)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
width: '100%'
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
height: 64,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
overflow: 'visible'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map(({ key, label, icon }) => (
|
{/* Left nav items */}
|
||||||
<BottomNavigationAction
|
{leftNavItems.map((item) => (
|
||||||
key={key}
|
<NavButton
|
||||||
label={label}
|
key={item.screen}
|
||||||
icon={icon}
|
item={item}
|
||||||
|
isActive={activeScreen === item.screen}
|
||||||
|
onClick={() => onNavigate(item.screen)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Center quick actions */}
|
||||||
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiBottomNavigationAction-label': {
|
display: 'flex',
|
||||||
fontSize: '0.75rem',
|
alignItems: 'center',
|
||||||
'&.Mui-selected': {
|
justifyContent: 'center',
|
||||||
fontSize: '0.75rem'
|
alignSelf: 'stretch',
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Backdrop
|
||||||
|
open={speedDialOpen}
|
||||||
|
onClick={closeSpeedDial}
|
||||||
|
sx={{
|
||||||
|
zIndex: theme.zIndex.speedDial - 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.15)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="Quick actions"
|
||||||
|
icon={<SpeedDialIcon icon={<AddIcon />} openIcon={<CloseIcon />} />}
|
||||||
|
open={speedDialOpen}
|
||||||
|
onOpen={openSpeedDial}
|
||||||
|
onClose={closeSpeedDial}
|
||||||
|
direction="up"
|
||||||
|
FabProps={{
|
||||||
|
color: 'primary',
|
||||||
|
sx: {
|
||||||
|
boxShadow: '0 4px 12px rgba(122, 33, 42, 0.35)',
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 6px 16px rgba(122, 33, 42, 0.45)'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onClick: () => setSpeedDialOpen((prev) => !prev)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'calc(64px + env(safe-area-inset-bottom, 0px) - 26px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: theme.zIndex.appBar + 1,
|
||||||
|
'& .MuiSpeedDial-fab': {
|
||||||
|
width: 52,
|
||||||
|
height: 52
|
||||||
|
},
|
||||||
|
// Position actions container as a centered point above FAB - use !important to override MUI defaults
|
||||||
|
'& .MuiSpeedDial-actions': {
|
||||||
|
position: 'absolute !important',
|
||||||
|
bottom: '56px !important',
|
||||||
|
left: '50% !important',
|
||||||
|
transform: 'translateX(-50%) !important',
|
||||||
|
width: '0 !important',
|
||||||
|
height: '0 !important',
|
||||||
|
padding: '0 !important',
|
||||||
|
margin: '0 !important',
|
||||||
|
flexDirection: 'row !important'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quickActions.map(({ action, label, icon }, index) => {
|
||||||
|
// Calculate semi-circle positions
|
||||||
|
// 4 items spread across 120 degrees (-60 to +60 from vertical)
|
||||||
|
const totalItems = quickActions.length;
|
||||||
|
const spreadAngle = 120; // degrees - reduced for tighter fit
|
||||||
|
const startAngle = 90 + spreadAngle / 2; // Start from left
|
||||||
|
const angleStep = spreadAngle / (totalItems - 1);
|
||||||
|
const angle = (startAngle - index * angleStep) * (Math.PI / 180);
|
||||||
|
const radius = 80; // Distance from center
|
||||||
|
const actionButtonOffset = 28; // Half of 56px wrapper width - centers the button instead of left edge
|
||||||
|
const x = Math.cos(angle) * radius - actionButtonOffset;
|
||||||
|
const y = -Math.sin(angle) * radius; // Negative because Y goes down in CSS
|
||||||
|
|
||||||
|
// Dynamic tooltip placement - left side items get left labels, right side get right labels
|
||||||
|
const tooltipPlacement = angle > Math.PI / 2 ? 'left' : 'right';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeedDialAction
|
||||||
|
key={action}
|
||||||
|
icon={icon}
|
||||||
|
tooltipTitle={label}
|
||||||
|
tooltipOpen
|
||||||
|
tooltipPlacement={tooltipPlacement}
|
||||||
|
onClick={() => {
|
||||||
|
onQuickAction(action);
|
||||||
|
closeSpeedDial();
|
||||||
|
}}
|
||||||
|
FabProps={{
|
||||||
|
sx: {
|
||||||
|
bgcolor: theme.palette.background.paper,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
boxShadow: theme.shadows[4],
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: theme.palette.action.hover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute !important',
|
||||||
|
left: '0 !important',
|
||||||
|
right: 'auto !important',
|
||||||
|
top: '0 !important',
|
||||||
|
bottom: 'auto !important',
|
||||||
|
margin: '0 !important',
|
||||||
|
padding: '0 !important',
|
||||||
|
transform: `translate(${x}px, ${y}px) !important`,
|
||||||
|
transition: 'transform 0.2s ease, opacity 0.2s ease',
|
||||||
|
'& .MuiSpeedDialAction-staticTooltipLabel': {
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '4px 8px'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SpeedDial>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right nav items */}
|
||||||
|
{rightNavItems.map((item) => (
|
||||||
|
<NavButton
|
||||||
|
key={item.screen}
|
||||||
|
item={item}
|
||||||
|
isActive={activeScreen === item.screen}
|
||||||
|
onClick={() => onNavigate(item.screen)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</MuiBottomNavigation>
|
|
||||||
|
{/* Hamburger menu button */}
|
||||||
|
<Box
|
||||||
|
onClick={() => {
|
||||||
|
closeSpeedDial();
|
||||||
|
onHamburgerPress();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
py: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
minWidth: 64,
|
||||||
|
'&:active': {
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: 'inherit',
|
||||||
|
p: 0.5
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<MenuRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'inherit',
|
||||||
|
mt: 0.25
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-export NavigationItem for backward compatibility if needed
|
||||||
|
export interface NavigationItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Hamburger menu drawer for mobile navigation
|
||||||
|
* @ai-context Bottom slide-up drawer with all navigation options
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
SwipeableDrawer,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
useTheme
|
||||||
|
} from '@mui/material';
|
||||||
|
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
|
||||||
|
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
|
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||||
|
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
||||||
|
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
||||||
|
import { MobileScreen } from '../../../core/store/navigation';
|
||||||
|
|
||||||
|
// iOS swipeable drawer configuration
|
||||||
|
const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
interface HamburgerDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: (screen: MobileScreen) => void;
|
||||||
|
activeScreen: MobileScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
screen: MobileScreen;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu items from bottom to top (reversed order in array for rendering)
|
||||||
|
const menuItems: MenuItem[] = [
|
||||||
|
{ screen: 'Settings', label: 'Settings', icon: <SettingsRoundedIcon /> },
|
||||||
|
{ screen: 'Documents', label: 'Documents', icon: <DescriptionRoundedIcon /> },
|
||||||
|
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> },
|
||||||
|
{ screen: 'Log Fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> },
|
||||||
|
{ screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
|
||||||
|
{ screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HamburgerDrawer: React.FC<HamburgerDrawerProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
activeScreen
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleNavigate = (screen: MobileScreen) => {
|
||||||
|
onNavigate(screen);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableDrawer
|
||||||
|
anchor="bottom"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpen={() => {}}
|
||||||
|
disableSwipeToOpen
|
||||||
|
disableBackdropTransition={!iOS}
|
||||||
|
disableDiscovery={iOS}
|
||||||
|
sx={{
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflow: 'visible'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ pb: 4 }}>
|
||||||
|
{/* Drawer handle */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: theme.palette.divider,
|
||||||
|
borderRadius: 2,
|
||||||
|
margin: '12px auto 8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
color: theme.palette.text.secondary
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Menu
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
|
<List disablePadding>
|
||||||
|
{menuItems.map(({ screen, label, icon }) => {
|
||||||
|
const isActive = activeScreen === screen;
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={screen}
|
||||||
|
onClick={() => handleNavigate(screen)}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
minHeight: 56,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? theme.palette.primary.main + '14'
|
||||||
|
: 'transparent',
|
||||||
|
borderLeft: isActive
|
||||||
|
? `3px solid ${theme.palette.primary.main}`
|
||||||
|
: '3px solid transparent',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive
|
||||||
|
? theme.palette.primary.main + '20'
|
||||||
|
: theme.palette.action.hover
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon
|
||||||
|
sx={{
|
||||||
|
minWidth: 40,
|
||||||
|
color: isActive
|
||||||
|
? theme.palette.primary.main
|
||||||
|
: theme.palette.text.secondary
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
color: isActive
|
||||||
|
? theme.palette.primary.main
|
||||||
|
: theme.palette.text.primary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</SwipeableDrawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Quick action menu popup for center FAB button
|
||||||
|
* @ai-context Opens above bottom navigation with quick shortcuts
|
||||||
|
* @ai-note Uses Portal to escape transformed parent containers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Backdrop,
|
||||||
|
useTheme,
|
||||||
|
Fade
|
||||||
|
} from '@mui/material';
|
||||||
|
import { quickActions, QuickAction } from './quickActions';
|
||||||
|
|
||||||
|
interface QuickActionMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAction: (action: QuickAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAction
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleAction = (action: QuickAction) => {
|
||||||
|
onAction(action);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use portal to render at document body level, escaping any transformed parents
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<Backdrop
|
||||||
|
open={open}
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{
|
||||||
|
zIndex: theme.zIndex.modal - 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 120,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: theme.zIndex.modal,
|
||||||
|
pointerEvents: open ? 'auto' : 'none',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Fade in={open}>
|
||||||
|
<Paper
|
||||||
|
elevation={8}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minWidth: 200,
|
||||||
|
backgroundColor: theme.palette.background.paper
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List disablePadding>
|
||||||
|
{quickActions.map(({ action, label, icon }) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={action}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
minHeight: 56,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.action.hover
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon
|
||||||
|
sx={{
|
||||||
|
minWidth: 40,
|
||||||
|
color: theme.palette.primary.main
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.95rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Fade>
|
||||||
|
</Box>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||||
|
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
|
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
||||||
|
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||||
|
|
||||||
|
export type QuickAction = 'log-fuel' | 'add-vehicle' | 'add-document' | 'add-maintenance';
|
||||||
|
|
||||||
|
export const quickActions: { action: QuickAction; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ action: 'log-fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> },
|
||||||
|
{ action: 'add-vehicle', label: 'Vehicle', icon: <DirectionsCarRoundedIcon /> },
|
||||||
|
{ action: 'add-document', label: 'Document', icon: <DescriptionRoundedIcon /> },
|
||||||
|
{ action: 'add-maintenance', label: 'Maintenance', icon: <BuildRoundedIcon /> }
|
||||||
|
];
|
||||||