diff --git a/src/App.jsx b/src/App.jsx index 2587f7c..a47bc51 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import { useCallback, useEffect } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' +import PageTransition from './components/animations/PageTransition' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' import { AuthenticationProvider } from './service/AuthenticationService' import { useNotification } from './service/NotificationProvider' @@ -74,7 +75,9 @@ const AppContent = () => { <> - + + + ) diff --git a/src/components/animations/AnimatedList.jsx b/src/components/animations/AnimatedList.jsx new file mode 100644 index 0000000..d18fff6 --- /dev/null +++ b/src/components/animations/AnimatedList.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import { Box } from '@mui/joy' +import { CSSTransition, TransitionGroup } from 'react-transition-group' +import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations' +import './PageTransition.css' + +const AnimatedList = ({ + children, + staggerDelay = 50, + animationType = 'stagger', // 'stagger', 'fade', 'slide' + direction = 'up', // 'up', 'down', 'left', 'right' + renderItem, + keyExtractor, + items, + ...boxProps +}) => { + // Handle both children and items patterns + let childrenArray + if (items && renderItem) { + childrenArray = items.map((item, index) => + React.cloneElement(renderItem(item, index), { + key: keyExtractor ? keyExtractor(item, index) : index + }) + ) + } else { + childrenArray = React.Children.toArray(children) + } + + const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay) + const prefersReducedMotion = useReducedMotion() + + // If user prefers reduced motion, render without animations + if (prefersReducedMotion) { + return ( + + {items && renderItem ? childrenArray : children} + + ) + } + + const getAnimationClass = () => { + switch (animationType) { + case 'fade': + return 'fade' + case 'slide': + return direction === 'up' ? 'slide-up' : 'page' + case 'stagger': + default: + return 'stagger' + } + } + + return ( + + + {childrenArray.map((child, index) => { + const isVisible = visibleItems.has(index) + + return ( + + + {child} + + + ) + })} + + + ) +} + +export default AnimatedList diff --git a/src/components/animations/LoadingScreen.jsx b/src/components/animations/LoadingScreen.jsx new file mode 100644 index 0000000..6ee18c9 --- /dev/null +++ b/src/components/animations/LoadingScreen.jsx @@ -0,0 +1,105 @@ +import { Box, CircularProgress, Typography } from '@mui/joy' +import { styled } from '@mui/joy/styles' + +// Styled components with keyframe animations +const LoadingContainer = styled(Box)({ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(8px)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + animation: 'fadeIn 0.3s ease-out', + '@keyframes fadeIn': { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + }, +}) + +const LoadingContent = styled(Box)({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '24px', + animation: 'slideUp 0.5s ease-out 0.2s both', + '@keyframes slideUp': { + from: { + opacity: 0, + transform: 'translateY(20px)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, + }, +}) + +const PulsingText = styled(Typography)({ + animation: 'pulse 2s ease-in-out infinite', + '@keyframes pulse': { + '0%, 100%': { + opacity: 1, + }, + '50%': { + opacity: 0.5, + }, + }, +}) + +const LogoContainer = styled(Box)({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginBottom: '24px', +}) + +const LoadingScreen = ({ + message = 'Loading...', + showLogo = true, + size = 'lg', +}) => { + return ( + + + {showLogo && ( + + + Done + tick + + + )} + + + + {message} + + + ) +} + +export default LoadingScreen diff --git a/src/components/animations/PageTransition.css b/src/components/animations/PageTransition.css new file mode 100644 index 0000000..afc073b --- /dev/null +++ b/src/components/animations/PageTransition.css @@ -0,0 +1,208 @@ +/* Page Transition Animations */ +.page-wrapper { + position: relative; + width: 100%; + min-height: 100vh; +} + +/* Enter animations */ +.page-enter { + opacity: 0; + transform: translateX(20px); +} + +.page-enter-active { + opacity: 1; + transform: translateX(0); + transition: opacity 300ms ease-out, transform 300ms ease-out; +} + +/* Exit animations */ +.page-exit { + opacity: 1; + transform: translateX(0); +} + +.page-exit-active { + opacity: 0; + transform: translateX(-20px); + transition: opacity 200ms ease-in, transform 200ms ease-in; +} + +/* Specific animation for back navigation */ +.page-back-enter { + opacity: 0; + transform: translateX(-20px); +} + +.page-back-enter-active { + opacity: 1; + transform: translateX(0); + transition: opacity 300ms ease-out, transform 300ms ease-out; +} + +.page-back-exit { + opacity: 1; + transform: translateX(0); +} + +.page-back-exit-active { + opacity: 0; + transform: translateX(20px); + transition: opacity 200ms ease-in, transform 200ms ease-in; +} + +/* Modal/overlay animations */ +.modal-enter { + opacity: 0; + transform: scale(0.95) translateY(10px); +} + +.modal-enter-active { + opacity: 1; + transform: scale(1) translateY(0); + transition: opacity 250ms ease-out, transform 250ms ease-out; +} + +.modal-exit { + opacity: 1; + transform: scale(1) translateY(0); +} + +.modal-exit-active { + opacity: 0; + transform: scale(0.95) translateY(10px); + transition: opacity 200ms ease-in, transform 200ms ease-in; +} + +/* Fade animation for simple transitions */ +.fade-enter { + opacity: 0; +} + +.fade-enter-active { + opacity: 1; + transition: opacity 250ms ease-out; +} + +.fade-exit { + opacity: 1; +} + +.fade-exit-active { + opacity: 0; + transition: opacity 200ms ease-in; +} + +/* Slide up animation for bottom sheets/panels */ +.slide-up-enter { + opacity: 0; + transform: translateY(100%); +} + +.slide-up-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 300ms ease-out, transform 300ms ease-out; +} + +.slide-up-exit { + opacity: 1; + transform: translateY(0); +} + +.slide-up-exit-active { + opacity: 0; + transform: translateY(100%); + transition: opacity 250ms ease-in, transform 250ms ease-in; +} + +/* Loading spinner animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.loading-spinner { + animation: spin 1s linear infinite; +} + +/* Skeleton loading animations */ +@keyframes skeleton-loading { + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200px 100%; + animation: skeleton-loading 1.5s infinite; +} + +/* Micro-interactions */ +.interactive-element { + transition: all 0.2s ease-out; + transform: translateZ(0); /* Enable GPU acceleration */ +} + +.interactive-element:hover { + transform: translateY(-2px) translateZ(0); +} + +.interactive-element:active { + transform: translateY(0) translateZ(0); + transition: all 0.1s ease-out; +} + +/* Stagger animation for lists */ +.stagger-enter { + opacity: 0; + transform: translateY(20px); +} + +.stagger-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 300ms ease-out, transform 300ms ease-out; +} + +/* Add animation delays for staggered effects */ +.stagger-enter-active:nth-child(1) { transition-delay: 0ms; } +.stagger-enter-active:nth-child(2) { transition-delay: 50ms; } +.stagger-enter-active:nth-child(3) { transition-delay: 100ms; } +.stagger-enter-active:nth-child(4) { transition-delay: 150ms; } +.stagger-enter-active:nth-child(5) { transition-delay: 200ms; } +.stagger-enter-active:nth-child(6) { transition-delay: 250ms; } +.stagger-enter-active:nth-child(7) { transition-delay: 300ms; } +.stagger-enter-active:nth-child(8) { transition-delay: 350ms; } + +/* Reduced motion for accessibility */ +@media (prefers-reduced-motion: reduce) { + .page-enter, + .page-enter-active, + .page-exit, + .page-exit-active, + .modal-enter, + .modal-enter-active, + .modal-exit, + .modal-exit-active, + .fade-enter, + .fade-enter-active, + .fade-exit, + .fade-exit-active, + .interactive-element, + .stagger-enter, + .stagger-enter-active { + transition: none !important; + animation: none !important; + transform: none !important; + } +} diff --git a/src/components/animations/PageTransition.jsx b/src/components/animations/PageTransition.jsx new file mode 100644 index 0000000..b2f714f --- /dev/null +++ b/src/components/animations/PageTransition.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useRef } from 'react' +import { CSSTransition, TransitionGroup } from 'react-transition-group' +import { useLocation } from 'react-router-dom' +import './PageTransition.css' + +// Route hierarchy for determining navigation direction +const routeHierarchy = { + '/': 0, + '/my/chores': 0, + '/chores': 1, + '/chores/create': 2, + '/settings': 1, + '/things': 1, + '/activities': 1, + '/points': 1, + '/labels': 1, + '/login': 0, + '/signup': 1, + '/landing': 0, +} + +const getRouteLevel = pathname => { + // Check for exact matches first + if (routeHierarchy[pathname] !== undefined) { + return routeHierarchy[pathname] + } + + // Check for dynamic routes (e.g., /chores/123/edit) + if (pathname.includes('/chores/') && pathname.includes('/edit')) { + return 3 + } + if (pathname.includes('/chores/') && pathname.includes('/history')) { + return 3 + } + if (pathname.includes('/chores/') && !pathname.includes('/edit') && !pathname.includes('/history')) { + return 2 + } + if (pathname.includes('/things/')) { + return 2 + } + + // Default level + return 1 +} + +const PageTransition = ({ children }) => { + const location = useLocation() + const prevLocation = useRef(location) + const isNavigatingBack = useRef(false) + + useEffect(() => { + const currentLevel = getRouteLevel(location.pathname) + const previousLevel = getRouteLevel(prevLocation.current.pathname) + + // Determine if we're navigating back (to a higher level in hierarchy) + isNavigatingBack.current = currentLevel < previousLevel + + prevLocation.current = location + }, [location]) + + const getTransitionClasses = () => { + if (location.pathname.includes('/login') || location.pathname.includes('/signup') || location.pathname.includes('/landing')) { + return 'fade' // Use fade for auth pages + } + + return isNavigatingBack.current ? 'page-back' : 'page' + } + + return ( + + +
+ {children} +
+
+
+ ) +} + +export default PageTransition diff --git a/src/components/animations/SkeletonLoader.jsx b/src/components/animations/SkeletonLoader.jsx new file mode 100644 index 0000000..244bddf --- /dev/null +++ b/src/components/animations/SkeletonLoader.jsx @@ -0,0 +1,138 @@ +import { Box, Skeleton } from '@mui/joy' + +const SkeletonLoader = ({ + type = 'card', + count = 1, + height = 100, + width = '100%', + variant = 'rectangular', + ...props +}) => { + const renderSkeleton = () => { + switch (type) { + case 'card': + return ( + + + + + + + + + + ) + + case 'list': + return ( + + + + + + + + + ) + + case 'chore': + return ( + + + + + + + + + + + + + + + + ) + + case 'profile': + return ( + + + + + + ) + + default: + return ( + + ) + } + } + + return ( + <> + {Array.from({ length: count }, (_, index) => ( + + {renderSkeleton()} + + ))} + + ) +} + +export default SkeletonLoader diff --git a/src/components/animations/SmoothButton.jsx b/src/components/animations/SmoothButton.jsx new file mode 100644 index 0000000..25d82fb --- /dev/null +++ b/src/components/animations/SmoothButton.jsx @@ -0,0 +1,107 @@ +import { Button } from '@mui/joy' +import { styled } from '@mui/joy/styles' + +const AnimatedButton = styled(Button)(({ theme }) => ({ + transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', + transform: 'translateZ(0)', // Enable GPU acceleration + position: 'relative', + overflow: 'hidden', + + '&:hover:not(:disabled)': { + transform: 'translateY(-2px) translateZ(0)', + boxShadow: theme.shadow.lg, + }, + + '&:active:not(:disabled)': { + transform: 'translateY(0) translateZ(0)', + transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)', + }, + + '&:focus-visible': { + outline: '2px solid', + outlineColor: theme.palette.primary[500], + outlineOffset: '2px', + }, + + // Ripple effect + '&::before': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: '0', + height: '0', + borderRadius: '50%', + background: 'currentColor', + opacity: 0.1, + transform: 'translate(-50%, -50%)', + transition: 'width 0.6s, height 0.6s', + }, + + '&:active::before': { + width: '300px', + height: '300px', + }, + + // Loading state + '&[data-loading="true"]': { + pointerEvents: 'none', + position: 'relative', + + '& > *': { + opacity: 0.6, + }, + + '&::after': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: '16px', + height: '16px', + border: '2px solid currentColor', + borderTop: '2px solid transparent', + borderRadius: '50%', + transform: 'translate(-50%, -50%)', + animation: 'spin 1s linear infinite', + }, + }, + + // Reduced motion support + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + transform: 'none !important', + + '&:hover:not(:disabled)': { + transform: 'none', + }, + + '&:active:not(:disabled)': { + transform: 'none', + }, + + '&::before': { + display: 'none', + }, + }, +})) + +const SmoothButton = ({ children, loading = false, onClick, ...props }) => { + const handleClick = event => { + if (loading) return + onClick?.(event) + } + + return ( + + {children} + + ) +} + +export default SmoothButton diff --git a/src/components/animations/SmoothCard.jsx b/src/components/animations/SmoothCard.jsx new file mode 100644 index 0000000..9d26f1e --- /dev/null +++ b/src/components/animations/SmoothCard.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import { Card } from '@mui/joy' +import { styled } from '@mui/joy/styles' + +const AnimatedCard = styled(Card)(({ theme }) => ({ + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transform: 'translateZ(0)', // Enable GPU acceleration + cursor: 'pointer', + position: 'relative', + + '&:hover': { + transform: 'translateY(-4px) translateZ(0)', + boxShadow: theme.shadow.lg, + }, + + '&:active': { + transform: 'translateY(-2px) translateZ(0)', + transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)', + }, + + // Subtle background animation on hover + '&::before': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)', + opacity: 0, + transition: 'opacity 0.3s ease', + pointerEvents: 'none', + borderRadius: 'inherit', + }, + + '&:hover::before': { + opacity: 1, + }, + + // Focus states for accessibility + '&:focus-visible': { + outline: '2px solid', + outlineColor: theme.palette.primary[500], + outlineOffset: '2px', + }, + + // Reduced motion support + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + transform: 'none !important', + + '&:hover': { + transform: 'none', + boxShadow: theme.shadow.md, // Still provide visual feedback + }, + + '&:active': { + transform: 'none', + }, + + '&::before': { + display: 'none', + }, + }, +})) + +const SmoothCard = ({ + children, + onClick, + animationDisabled = false, + ...props +}) => { + if (animationDisabled) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) +} + +export default SmoothCard diff --git a/src/components/animations/StaggeredList.jsx b/src/components/animations/StaggeredList.jsx new file mode 100644 index 0000000..32970c5 --- /dev/null +++ b/src/components/animations/StaggeredList.jsx @@ -0,0 +1,54 @@ +import { Box } from '@mui/joy' +import React, { useEffect, useState } from 'react' +import { CSSTransition, TransitionGroup } from 'react-transition-group' +import './PageTransition.css' + +const StaggeredList = ({ + children, + staggerDelay = 50, + initialDelay = 0, + animate = true, +}) => { + const [isVisible, setIsVisible] = useState(!animate) + + useEffect(() => { + if (animate) { + const timer = setTimeout(() => { + setIsVisible(true) + }, initialDelay) + + return () => clearTimeout(timer) + } + }, [animate, initialDelay]) + + if (!animate) { + return {children} + } + + const childrenArray = React.Children.toArray(children) + + return ( + + + {isVisible && + childrenArray.map((child, index) => ( + + {child} + + ))} + + + ) +} + +export default StaggeredList diff --git a/src/components/animations/index.js b/src/components/animations/index.js new file mode 100644 index 0000000..54af463 --- /dev/null +++ b/src/components/animations/index.js @@ -0,0 +1,10 @@ +// Animation Components +export { default as AnimatedList } from './AnimatedList' +export { default as LoadingScreen } from './LoadingScreen' +export { default as PageTransition } from './PageTransition' +export { default as SmoothButton } from './SmoothButton' +export { default as SmoothCard } from './SmoothCard' +export { default as StaggeredList } from './StaggeredList' + +// Animation Styles +import './PageTransition.css' diff --git a/src/hooks/useAnimations.js b/src/hooks/useAnimations.js new file mode 100644 index 0000000..7ecc5b2 --- /dev/null +++ b/src/hooks/useAnimations.js @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' + +// Hook to detect user's motion preferences +export const useReducedMotion = () => { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + setPrefersReducedMotion(mediaQuery.matches) + + const handleChange = (event) => { + setPrefersReducedMotion(event.matches) + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + return prefersReducedMotion +} + +// Hook for staggered animations +export const useStaggeredAnimation = (itemCount, delay = 50) => { + const [visibleItems, setVisibleItems] = useState(new Set()) + const prefersReducedMotion = useReducedMotion() + + useEffect(() => { + if (prefersReducedMotion) { + // Show all items immediately if reduced motion is preferred + setVisibleItems(new Set(Array.from({ length: itemCount }, (_, i) => i))) + return + } + + const timeouts = [] + + // Stagger the appearance of items + for (let i = 0; i < itemCount; i++) { + const timeout = setTimeout(() => { + setVisibleItems(prev => new Set([...prev, i])) + }, i * delay) + + timeouts.push(timeout) + } + + return () => { + timeouts.forEach(clearTimeout) + } + }, [itemCount, delay, prefersReducedMotion]) + + return visibleItems +} + +// Hook for intersection observer animations +export const useInViewAnimation = (threshold = 0.1) => { + const [isInView, setIsInView] = useState(false) + const [element, setElement] = useState(null) + + useEffect(() => { + if (!element) return + + const observer = new IntersectionObserver( + ([entry]) => { + setIsInView(entry.isIntersecting) + }, + { threshold } + ) + + observer.observe(element) + + return () => { + observer.unobserve(element) + } + }, [element, threshold]) + + return [setElement, isInView] +} + +// Hook for page transition context +export const usePageTransition = () => { + const [isTransitioning, setIsTransitioning] = useState(false) + + const startTransition = () => setIsTransitioning(true) + const endTransition = () => setIsTransitioning(false) + + return { + isTransitioning, + startTransition, + endTransition, + } +} diff --git a/src/index.css b/src/index.css index b5c61c9..3586c73 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,51 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Global animations for smooth interactions */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Smooth scrolling for better UX */ +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +/* Global focus styles for better accessibility */ +*:focus-visible { + outline: 2px solid #0ea5e9; + outline-offset: 2px; + border-radius: 4px; +} + +/* Improve button and interactive element performance */ +button, a, [role="button"] { + transform: translateZ(0); + backface-visibility: hidden; +} + +/* Ensure smooth transitions for dynamic content */ +* { + box-sizing: border-box; +} + +/* Optimize animations for performance */ +.animate-optimized { + will-change: transform, opacity; +} + +.animate-optimized.animation-complete { + will-change: auto; +} diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 195b744..3259a30 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -14,13 +14,13 @@ import { import moment from 'moment' import { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' +import { LoadingScreen, SmoothCard } from '../../components/animations' import { DeleteChoreHistory, GetAllCircleMembers, GetChoreHistory, UpdateChoreHistory, } from '../../utils/Fetcher' -import LoadingComponent from '../components/Loading' import EditHistoryModal from '../Modals/EditHistoryModal' import HistoryCard from './HistoryCard' @@ -143,7 +143,7 @@ const ChoreHistory = () => { } if (isLoading) { - return + return } if (!choreHistory.length) { return (