diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..a552bc6
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -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
diff --git a/.playwright-mcp/menu-bottom-200.png b/.playwright-mcp/menu-bottom-200.png
new file mode 100644
index 0000000..26c3d06
Binary files /dev/null and b/.playwright-mcp/menu-bottom-200.png differ
diff --git a/.playwright-mcp/menu-debug.png b/.playwright-mcp/menu-debug.png
new file mode 100644
index 0000000..85f3d53
Binary files /dev/null and b/.playwright-mcp/menu-debug.png differ
diff --git a/.playwright-mcp/menu-final-position.png b/.playwright-mcp/menu-final-position.png
new file mode 100644
index 0000000..40f3a85
Binary files /dev/null and b/.playwright-mcp/menu-final-position.png differ
diff --git a/.playwright-mcp/menu-portal-test.png b/.playwright-mcp/menu-portal-test.png
new file mode 100644
index 0000000..85f3d53
Binary files /dev/null and b/.playwright-mcp/menu-portal-test.png differ
diff --git a/.playwright-mcp/quick-action-menu-fixed.png b/.playwright-mcp/quick-action-menu-fixed.png
new file mode 100644
index 0000000..534b4f8
Binary files /dev/null and b/.playwright-mcp/quick-action-menu-fixed.png differ
diff --git a/.playwright-mcp/quick-action-menu-position.png b/.playwright-mcp/quick-action-menu-position.png
new file mode 100644
index 0000000..85f3d53
Binary files /dev/null and b/.playwright-mcp/quick-action-menu-position.png differ
diff --git a/.playwright-mcp/speeddial-check.png b/.playwright-mcp/speeddial-check.png
new file mode 100644
index 0000000..4973b7f
Binary files /dev/null and b/.playwright-mcp/speeddial-check.png differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 63e9225..2ba6600 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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: },
- { key: "Vehicles", label: "Vehicles", icon: },
- { key: "Log Fuel", label: "Log Fuel", icon: },
- { key: "Stations", label: "Stations", icon: },
- { key: "Documents", label: "Documents", icon: },
- { key: "Settings", label: "Settings", icon: },
- ];
+ // 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() {
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)}
+ />
+
+ 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}
/>
diff --git a/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx b/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx
index b655d70..cf09c15 100644
--- a/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx
+++ b/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx
@@ -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 = ({
if (!optimisticVehicles.length) {
return (
-
+
No vehicles added yet
-
+
Add your first vehicle to get started
+ }
+ onClick={() => onAddVehicle?.()}
+ sx={{ minWidth: 160 }}
+ >
+ Add Vehicle
+
-
- {/* Floating Action Button */}
- onAddVehicle?.()}
- >
-
-
);
}
return (
-
+
{filteredVehicles.map((vehicle) => (
@@ -126,20 +121,6 @@ export const VehiclesMobileScreen: React.FC = ({
))}
-
- {/* Floating Action Button */}
- onAddVehicle?.()}
- >
-
-
);
diff --git a/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx b/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx
index ad997d7..b455101 100644
--- a/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx
+++ b/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx
@@ -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: },
+ { screen: 'Vehicles', label: 'Vehicles', icon: },
+];
+
+const rightNavItems: NavItem[] = [
+ { screen: 'Stations', label: 'Stations', icon: },
+];
+
+export const BottomNavigation: React.FC = ({
+ 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 }) => (
+ {
+ 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
+ }
+ }}
+ >
+
+ {item.icon}
+
+
+ {item.label}
+
+
+ );
+
+ return (
+
+ {/* Left nav items */}
+ {leftNavItems.map((item) => (
+ onNavigate(item.screen)}
+ />
+ ))}
+
+ {/* Center quick actions */}
+
+
+ } openIcon={} />}
+ 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 (
+ {
+ 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'
+ }
+ }}
+ />
+ );
+ })}
+
+
+
+ {/* Right nav items */}
+ {rightNavItems.map((item) => (
+ onNavigate(item.screen)}
+ />
+ ))}
+
+ {/* Hamburger menu button */}
+ {
+ 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
+ }
+ }}
+ >
+
+
+
+
+ More
+
+
+
+ );
+};
+
+// 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 = ({
- items,
- activeItem,
- onItemSelect
-}) => {
- const activeIndex = items.findIndex(item => item.key === activeItem);
-
- return (
- onItemSelect(items[newValue].key)}
- sx={{
- position: 'fixed',
- bottom: 0,
- left: 0,
- right: 0,
- zIndex: 1000,
- width: '100%'
- }}
- >
- {items.map(({ key, label, icon }) => (
-
- ))}
-
- );
-};
\ No newline at end of file
diff --git a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx
new file mode 100644
index 0000000..d8ecf65
--- /dev/null
+++ b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx
@@ -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: },
+ { screen: 'Documents', label: 'Documents', icon: },
+ { screen: 'Stations', label: 'Stations', icon: },
+ { screen: 'Log Fuel', label: 'Log Fuel', icon: },
+ { screen: 'Vehicles', label: 'Vehicles', icon: },
+ { screen: 'Dashboard', label: 'Dashboard', icon: },
+];
+
+export const HamburgerDrawer: React.FC = ({
+ open,
+ onClose,
+ onNavigate,
+ activeScreen
+}) => {
+ const theme = useTheme();
+
+ const handleNavigate = (screen: MobileScreen) => {
+ onNavigate(screen);
+ onClose();
+ };
+
+ return (
+ {}}
+ disableSwipeToOpen
+ disableBackdropTransition={!iOS}
+ disableDiscovery={iOS}
+ sx={{
+ '& .MuiDrawer-paper': {
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ maxHeight: '60vh',
+ overflow: 'visible'
+ }
+ }}
+ >
+
+ {/* Drawer handle */}
+
+
+ {/* Header */}
+
+ Menu
+
+
+ {/* Menu Items */}
+
+ {menuItems.map(({ screen, label, icon }) => {
+ const isActive = activeScreen === screen;
+ return (
+ 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
+ }
+ }}
+ >
+
+ {icon}
+
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/frontend/src/shared-minimal/components/mobile/QuickActionMenu.tsx b/frontend/src/shared-minimal/components/mobile/QuickActionMenu.tsx
new file mode 100644
index 0000000..74ae558
--- /dev/null
+++ b/frontend/src/shared-minimal/components/mobile/QuickActionMenu.tsx
@@ -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 = ({
+ 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(
+ <>
+
+
+
+
+
+ {quickActions.map(({ action, label, icon }) => (
+ handleAction(action)}
+ sx={{
+ py: 1.5,
+ px: 2,
+ minHeight: 56,
+ '&:hover': {
+ backgroundColor: theme.palette.action.hover
+ }
+ }}
+ >
+
+ {icon}
+
+
+
+ ))}
+
+
+
+
+ >,
+ document.body
+ );
+};
diff --git a/frontend/src/shared-minimal/components/mobile/quickActions.tsx b/frontend/src/shared-minimal/components/mobile/quickActions.tsx
new file mode 100644
index 0000000..fc0a7d1
--- /dev/null
+++ b/frontend/src/shared-minimal/components/mobile/quickActions.tsx
@@ -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: },
+ { action: 'add-vehicle', label: 'Vehicle', icon: },
+ { action: 'add-document', label: 'Document', icon: },
+ { action: 'add-maintenance', label: 'Maintenance', icon: }
+];