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 +
- - {/* 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: } +];