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 { ThemeProvider } from '@mui/material/styles';
|
||||
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 { Layout } from './components/Layout';
|
||||
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 AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
|
||||
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 { RouteSuspense } from './components/SuspenseWrappers';
|
||||
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
||||
@@ -362,15 +359,29 @@ function App() {
|
||||
return undefined;
|
||||
}, [goBack, canGoBack, mobileMode]);
|
||||
|
||||
// Mobile navigation items
|
||||
const mobileNavItems: NavigationItem[] = [
|
||||
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
|
||||
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
|
||||
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
|
||||
{ key: "Stations", label: "Stations", icon: <LocalGasStationRoundedIcon /> },
|
||||
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
|
||||
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
|
||||
];
|
||||
// Menu state
|
||||
const [hamburgerOpen, setHamburgerOpen] = useState(false);
|
||||
|
||||
// Quick action handler
|
||||
const handleQuickAction = useCallback((action: QuickAction) => {
|
||||
switch (action) {
|
||||
case 'log-fuel':
|
||||
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 });
|
||||
|
||||
@@ -689,9 +700,8 @@ function App() {
|
||||
</Layout>
|
||||
|
||||
<BottomNavigation
|
||||
items={mobileNavItems}
|
||||
activeItem={activeScreen}
|
||||
onItemSelect={(screen) => startTransition(() => {
|
||||
activeScreen={activeScreen}
|
||||
onNavigate={(screen) => startTransition(() => {
|
||||
// Prefetch data for the target screen
|
||||
prefetchForNavigation(screen);
|
||||
|
||||
@@ -704,8 +714,29 @@ function App() {
|
||||
}
|
||||
|
||||
// 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>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 { useVehicles } from '../hooks/useVehicles';
|
||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||
@@ -82,38 +82,33 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
|
||||
if (!optimisticVehicles.length) {
|
||||
return (
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Section title="Vehicles">
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
No vehicles added yet
|
||||
</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
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Add Vehicle
|
||||
</Button>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileVehiclesSuspense>
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
|
||||
<Grid container spacing={2}>
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
@@ -126,20 +121,6 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
</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 { 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';
|
||||
|
||||
interface BottomNavigationProps {
|
||||
activeScreen: MobileScreen;
|
||||
onNavigate: (screen: MobileScreen) => void;
|
||||
onQuickAction: (action: QuickAction) => void;
|
||||
onHamburgerPress: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
screen: MobileScreen;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const leftNavItems: NavItem[] = [
|
||||
{ screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },
|
||||
{ screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> },
|
||||
];
|
||||
|
||||
export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
||||
activeScreen,
|
||||
onNavigate,
|
||||
onQuickAction,
|
||||
onHamburgerPress
|
||||
}) => {
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
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'
|
||||
}}
|
||||
>
|
||||
{/* Left nav items */}
|
||||
{leftNavItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.screen}
|
||||
item={item}
|
||||
isActive={activeScreen === item.screen}
|
||||
onClick={() => onNavigate(item.screen)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Center quick actions */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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;
|
||||
}
|
||||
|
||||
interface BottomNavigationProps {
|
||||
items: NavigationItem[];
|
||||
activeItem: string;
|
||||
onItemSelect: (key: string) => void;
|
||||
}
|
||||
|
||||
export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
||||
items,
|
||||
activeItem,
|
||||
onItemSelect
|
||||
}) => {
|
||||
const activeIndex = items.findIndex(item => item.key === activeItem);
|
||||
|
||||
return (
|
||||
<MuiBottomNavigation
|
||||
showLabels
|
||||
value={activeIndex}
|
||||
onChange={(_, newValue) => onItemSelect(items[newValue].key)}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, label, icon }) => (
|
||||
<BottomNavigationAction
|
||||
key={key}
|
||||
label={label}
|
||||
icon={icon}
|
||||
sx={{
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
fontSize: '0.75rem',
|
||||
'&.Mui-selected': {
|
||||
fontSize: '0.75rem'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</MuiBottomNavigation>
|
||||
);
|
||||
};
|
||||
@@ -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 /> }
|
||||
];
|
||||