Mobile UX fixes

This commit is contained in:
Eric Gullickson
2025-12-17 21:46:44 -06:00
parent b611b56336
commit c13e17f0eb
14 changed files with 671 additions and 99 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
);
};

View File

@@ -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 /> }
];