From 98d010a6e6ec4c2945ce4193b534351f321fb3ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:56:09 +0000 Subject: [PATCH 1/5] Improve iOS experience to align with Apple HIG - P1: Convert TaskEditDialog, StartDayDialog, ArchiveEditDialog, and DeleteConfirmationDialog to adaptive bottom sheets on iOS using vaul Drawer via new AdaptiveDialog component; web rendering is unchanged. DeleteConfirmationDialog reverses button order per iOS action-sheet convention (destructive first, Cancel last). - P2: Replace unreliable visibilitychange handler in TimeTrackingContext with useAppLifecycle hook that fires @capacitor/app's appStateChange at the Swift layer before WKWebView freezes, with a visibilitychange fallback for web. - P3: Add useHaptics hook (light/medium/heavy impact, success/error notifications) wired to: tab-bar navigation, task Edit/Delete buttons, delete confirmation, startNewTask success, postDay success, and sync failure paths. - P4: Add useStatusBar hook that syncs iOS status bar text colour with the system dark/light mode. Update apple-mobile-web-app-status-bar-style to black-translucent so the web view extends behind the status bar. - P5: Gate desktop SiteNavigationMenu behind !isIosBuild in PageLayout; replace with new IosPageHeader (sticky, 17px SF-style title, back chevron, safe-area-inset-top padding). Inject ios-build class on body in main.tsx and prevent double-stacking of safe-area padding in pwa.css. - P6: Configure Capacitor Keyboard plugin (resize: body) in capacitor.config.ts. Add useKeyboardHeight hook and apply its value as paddingBottom on DrawerContent so form fields scroll above the keyboard. Add scroll-margin-bottom rule to pwa.css for native scroll-into-view. - P7: Add useLongPress hook and ContextMenu (right-click on desktop, long-press on iOS) to TaskItem with Edit and Delete actions. Hide always-visible action buttons on iOS builds. - P8: Bump button sm size from h-9 to h-10 (40px); enforce min-height 44px on all non-hidden buttons at mobile widths via pwa.css. - P9: Restore native iOS rubber-band bounce by overriding overscroll-behavior-y on #root inside the @supports iOS block. Add touch-action and overscroll-behavior: contain on vaul drawer to prevent scroll bleed through open sheets. - P10: Wrap Routes in a keyed AnimatedRoutes component; apply page-transition-enter CSS animation (subtle 30px slide + fade, 280ms) on iOS route changes via @supports (-webkit-touch-callout: none). https://claude.ai/code/session_014br3fcYa3SDmGjBHnDYZec --- capacitor.config.ts | 8 +- index.html | 2 +- package-lock.json | 40 ++++++ package.json | 6 +- public/pwa.css | 60 +++++++-- src/App.tsx | 51 ++++++-- src/components/ArchiveEditDialog.tsx | 26 ++-- src/components/DeleteConfirmationDialog.tsx | 120 +++++++++++------ src/components/IosPageHeader.tsx | 61 +++++++++ src/components/MobileNav.tsx | 3 + src/components/PageLayout.tsx | 82 +++++++----- src/components/StartDayDialog.tsx | 40 +++--- src/components/TaskEditDialog.tsx | 26 ++-- src/components/TaskItem.tsx | 93 +++++++++---- src/components/ui/adaptive-dialog.tsx | 138 ++++++++++++++++++++ src/components/ui/button.tsx | 2 +- src/components/ui/drawer.tsx | 37 +++--- src/contexts/TimeTrackingContext.tsx | 44 ++++--- src/hooks/useAppLifecycle.ts | 37 ++++++ src/hooks/useHaptics.ts | 51 ++++++++ src/hooks/useKeyboardHeight.ts | 34 +++++ src/hooks/useLongPress.ts | 33 +++++ src/hooks/useStatusBar.ts | 18 +++ src/main.tsx | 4 + 24 files changed, 811 insertions(+), 205 deletions(-) create mode 100644 src/components/IosPageHeader.tsx create mode 100644 src/components/ui/adaptive-dialog.tsx create mode 100644 src/hooks/useAppLifecycle.ts create mode 100644 src/hooks/useHaptics.ts create mode 100644 src/hooks/useKeyboardHeight.ts create mode 100644 src/hooks/useLongPress.ts create mode 100644 src/hooks/useStatusBar.ts diff --git a/capacitor.config.ts b/capacitor.config.ts index a65c557..a761423 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -26,7 +26,13 @@ const config: CapacitorConfig = { }, plugins: { - // Placeholder — native plugin config goes here in Phase 4 (widget bridge) + Keyboard: { + // Shrink the body when the keyboard appears so fixed-bottom UI (MobileNav, + // bottom sheets) moves up with the keyboard automatically. + resize: 'body', + style: 'default', + resizeOnFullScreen: true + } } }; diff --git a/index.html b/index.html index b764c49..24c0ac4 100644 --- a/index.html +++ b/index.html @@ -25,7 +25,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 53bc9bc..6cbee5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "timetraked", "version": "0.45.0", "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.3.1", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.3.1", + "@capacitor/keyboard": "^8.0.3", + "@capacitor/status-bar": "^8.0.2", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -1722,6 +1726,15 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", + "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz", @@ -1764,6 +1777,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/haptics": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", + "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/ios": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz", @@ -1773,6 +1795,24 @@ "@capacitor/core": "^8.3.0" } }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz", + "integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", + "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/package.json b/package.json index bf42e97..a0ab759 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "screenshots:headed": "playwright test screenshots.spec.ts --headed" }, "dependencies": { + "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.3.1", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.3.1", + "@capacitor/keyboard": "^8.0.3", + "@capacitor/status-bar": "^8.0.2", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -81,6 +85,7 @@ "@rollup/plugin-terser": ">=0.4.4" }, "devDependencies": { + "@capacitor/cli": "^8.3.1", "@eslint/js": "^9.39.4", "@playwright/test": "^1.56.1", "@tailwindcss/typography": "^0.5.15", @@ -88,7 +93,6 @@ "@testing-library/react": "^14.3.1", "@types/node": "^22.19.17", "@types/react": "^18.3.28", - "@capacitor/cli": "^8.3.1", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", diff --git a/public/pwa.css b/public/pwa.css index b020bce..7d42e26 100644 --- a/public/pwa.css +++ b/public/pwa.css @@ -15,6 +15,12 @@ body { padding-right: var(--safe-area-inset-right); } +/* IosPageHeader consumes the safe-area-inset-top itself via paddingTop inline style. + Prevent double-stacking when running as a Capacitor iOS build. */ +.ios-build body { + padding-top: 0; +} + /* Add padding bottom for mobile nav on mobile devices */ /* Note: iOS-specific padding is handled in the iOS section below */ @media (max-width: 768px) { @@ -85,23 +91,24 @@ button, } } -/* Pull-to-refresh prevention (optional, can be enabled if needed) */ +/* Prevent pull-to-refresh on non-iOS browsers */ body { overscroll-behavior-y: contain; } +/* Restore native rubber-band bounce on iOS — body is fixed/overflow:hidden there + so this applies to #root (the actual scroll container). */ +@supports (-webkit-touch-callout: none) { + #root { + overscroll-behavior-y: auto; + } +} + /* Improve button touch targets on mobile */ @media (max-width: 768px) { - /* button:not(.icon-only) { */ - /* min-height: 44px; */ - /* min-width: 44px; */ - /* padding: 0.75rem 1rem; */ - /* } */ - - /* Larger tap targets for icon buttons */ - button.icon-only { - min-width: 44px; + button:not([aria-hidden="true"]) { min-height: 44px; + min-width: 44px; } } @@ -138,6 +145,15 @@ body { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } +/* Ensure focused inputs scroll into view above the keyboard with enough clearance */ +@supports (-webkit-touch-callout: none) { + input:focus, + textarea:focus, + select:focus { + scroll-margin-bottom: 24px; + } +} + /* iOS specific fixes */ @supports (-webkit-touch-callout: none) { @@ -198,6 +214,30 @@ body { } } +/* Prevent scroll from leaking through open bottom sheets to the page beneath */ +[data-vaul-drawer] { + touch-action: pan-y; + overscroll-behavior: contain; +} + +/* iOS page transition: subtle slide-in from the right on route change */ +@supports (-webkit-touch-callout: none) { + .page-transition-enter { + animation: iosSlideIn 280ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + } + + @keyframes iosSlideIn { + from { + transform: translateX(30px); + opacity: 0.8; + } + to { + transform: translateX(0); + opacity: 1; + } + } +} + /* Android specific fixes */ @media (max-width: 768px) { diff --git a/src/App.tsx b/src/App.tsx index 80e2032..335ebbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { HashRouter, Routes, Route, Navigate } from "react-router-dom"; +import { HashRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; import { AuthProvider } from "@/contexts/AuthContext"; import { OfflineProvider } from "@/contexts/OfflineContext"; import { TimeTrackingProvider } from "@/contexts/TimeTrackingContext"; import { useAuth } from "@/hooks/useAuth"; -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useState, useEffect } from "react"; import { InstallPrompt } from "@/components/InstallPrompt"; import { UpdateNotification } from "@/components/UpdateNotification"; +import { useStatusBar } from "@/hooks/useStatusBar"; const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; import { MobileNav } from "@/components/MobileNav"; @@ -38,26 +39,50 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { return <>{children}; }; +const AppShell = () => { + const [isDark, setIsDark] = useState( + () => window.matchMedia("(prefers-color-scheme: dark)").matches + ); + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => setIsDark(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + useStatusBar(isDark); + return null; +}; + +const AnimatedRoutes = () => { + const location = useLocation(); + return ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + +
+ ); +}; + const App = () => ( + }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - + diff --git a/src/components/ArchiveEditDialog.tsx b/src/components/ArchiveEditDialog.tsx index f4b41cf..ea5d39e 100644 --- a/src/components/ArchiveEditDialog.tsx +++ b/src/components/ArchiveEditDialog.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from "react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, +} from "@/components/ui/adaptive-dialog"; import { AlertDialog, AlertDialogAction, @@ -260,16 +260,16 @@ export const ArchiveEditDialog: React.FC = ({ }; return ( - - - + + +
- + {formatDate(day.startTime)} - +
-
+
@@ -621,7 +621,7 @@ export const ArchiveEditDialog: React.FC = ({ - -
+ + ); }; diff --git a/src/components/DeleteConfirmationDialog.tsx b/src/components/DeleteConfirmationDialog.tsx index 4ef48df..ddd4f02 100644 --- a/src/components/DeleteConfirmationDialog.tsx +++ b/src/components/DeleteConfirmationDialog.tsx @@ -1,45 +1,91 @@ -import React from 'react'; +import React from "react"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '@/components/ui/alert-dialog'; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, + AdaptiveDialogDescription, + AdaptiveDialogFooter, +} from "@/components/ui/adaptive-dialog"; +import { Button } from "@/components/ui/button"; +import { useHaptics } from "@/hooks/useHaptics"; + +const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; interface DeleteConfirmationDialogProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - taskTitle: string; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + taskTitle: string; } export const DeleteConfirmationDialog: React.FC< - DeleteConfirmationDialogProps + DeleteConfirmationDialogProps > = ({ isOpen, onClose, onConfirm, taskTitle }) => { - return ( - - - - Delete Task - - Are you sure you want to delete "{taskTitle}"? This action cannot be - undone. - - - - Cancel - - Delete - - - - - ); + const { heavyImpact } = useHaptics(); + + if (isIosBuild) { + return ( + + + + Delete Task + + Are you sure you want to delete “{taskTitle}”? This action cannot be + undone. + + + + {/* Destructive action first on iOS (action-sheet convention) */} + + + + + + ); + } + + return ( + + + + Delete Task + + Are you sure you want to delete “{taskTitle}”? This action cannot be + undone. + + + + Cancel + + Delete + + + + + ); }; diff --git a/src/components/IosPageHeader.tsx b/src/components/IosPageHeader.tsx new file mode 100644 index 0000000..22e8e47 --- /dev/null +++ b/src/components/IosPageHeader.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface IosPageHeaderProps { + title?: ReactNode; + actions?: ReactNode; + /** Show back chevron. Pass true to use history.back(), or a path string to navigate there. */ + back?: boolean | string; +} + +export const IosPageHeader = ({ title, actions, back }: IosPageHeaderProps) => { + const navigate = useNavigate(); + + const handleBack = () => { + if (typeof back === "string") { + navigate(back); + } else { + navigate(-1); + } + }; + + return ( +
+
+ {back ? ( + + ) : ( +
+ )} + + {title && ( +

+ {title} +

+ )} + + {actions ? ( +
+ {actions} +
+ ) : ( +
+ )} +
+
+ ); +}; diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index 8c04b2e..5dfae11 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -2,10 +2,12 @@ import { memo } from "react"; import { Link, useLocation } from "react-router-dom"; import { Home, Archive, Settings, PaperclipIcon, ClipboardList } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; +import { useHaptics } from "@/hooks/useHaptics"; export const MobileNav = memo(function MobileNav() { const location = useLocation(); const { isAuthenticated } = useAuth(); + const { lightImpact } = useHaptics(); const isActive = (path: string) => { return location.pathname === path; @@ -58,6 +60,7 @@ export const MobileNav = memo(function MobileNav() { { - return ( -
- - {title !== undefined && ( -
-
-

- {icon} - {title} -

- {actions &&
{actions}
} -
- {description && ( -

{description}

- )} -
- )} - {children} -
- ); + return ( +
+ {isIosBuild ? ( + + ) : ( + + )} + {!isIosBuild && title !== undefined && ( +
+
+

+ {icon} + {title} +

+ {actions &&
{actions}
} +
+ {description && ( +

{description}

+ )} +
+ )} + {isIosBuild && description && ( +

{description}

+ )} + {children} +
+ ); }; diff --git a/src/components/StartDayDialog.tsx b/src/components/StartDayDialog.tsx index 540d43e..fb1f8b1 100644 --- a/src/components/StartDayDialog.tsx +++ b/src/components/StartDayDialog.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from 'react'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter -} from '@/components/ui/dialog'; + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, + AdaptiveDialogDescription, + AdaptiveDialogFooter, +} from "@/components/ui/adaptive-dialog"; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -62,19 +62,19 @@ export const StartDayDialog: React.FC = ({ }; return ( - - - - + + + + Start Your Work Day - - + + Choose the date and time when you started working today. - - + + -
+
= ({
- + @@ -107,8 +107,8 @@ export const StartDayDialog: React.FC = ({ Start Day - - -
+ + + ); }; diff --git a/src/components/TaskEditDialog.tsx b/src/components/TaskEditDialog.tsx index 32bba35..94a3f7a 100644 --- a/src/components/TaskEditDialog.tsx +++ b/src/components/TaskEditDialog.tsx @@ -1,11 +1,11 @@ import React, { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog'; + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, +} from "@/components/ui/adaptive-dialog"; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -205,14 +205,14 @@ export const TaskEditDialog: React.FC = ({ }; return ( - - - - + + + + Edit Task - - + +
@@ -468,7 +468,7 @@ export const TaskEditDialog: React.FC = ({
-
-
+ + ); }; diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index e1a4b19..873f21f 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -1,7 +1,16 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { Task } from "@/contexts/TimeTrackingContext"; import { useTimeTracking } from "@/hooks/useTimeTracking"; import { Button } from "@/components/ui/button"; +import { useHaptics } from "@/hooks/useHaptics"; +import { useLongPress } from "@/hooks/useLongPress"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Card, CardContent } from "@/components/ui/card"; import { TaskEditDialog } from "@/components/TaskEditDialog"; import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog"; @@ -32,12 +41,29 @@ export const TaskItem: React.FC = ({ const { categories } = useTimeTracking(); const [showEditDialog, setShowEditDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { lightImpact, mediumImpact } = useHaptics(); + const contextMenuTriggerRef = useRef(null); + + const longPressHandlers = useLongPress(() => { + mediumImpact(); + // Simulate a right-click to open the Radix context menu programmatically + if (contextMenuTriggerRef.current) { + contextMenuTriggerRef.current.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true }) + ); + } + }); const duration = task.duration || (isActive ? currentDuration : 0); const category = categories.find((c) => c.id === task.category); + const isIosBuild = import.meta.env.VITE_IOS_BUILD === "true"; + return ( <> + + +
= ({
-
- - -
+ {!isIosBuild && ( +
+ + +
+ )} + +
+ + { lightImpact(); setShowEditDialog(true); }} + > + + Edit Task + + + { mediumImpact(); setShowDeleteDialog(true); }} + className="text-destructive focus:text-destructive" + > + + Delete Task + + +
void + children: React.ReactNode + /** vaul snap points, iOS only */ + snapPoints?: (number | string)[] +} + +export const AdaptiveDialog = ({ + open, + onOpenChange, + children, + snapPoints, +}: AdaptiveDialogProps) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +interface AdaptiveDialogContentProps { + children: React.ReactNode + className?: string +} + +export const AdaptiveDialogContent = ({ + children, + className, +}: AdaptiveDialogContentProps) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export const AdaptiveDialogHeader = ({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) => { + if (isIosBuild) { + return {children} + } + return {children} +} + +export const AdaptiveDialogTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ children, className, ...props }, ref) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +}) +AdaptiveDialogTitle.displayName = "AdaptiveDialogTitle" + +export const AdaptiveDialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ children, className, ...props }, ref) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +}) +AdaptiveDialogDescription.displayName = "AdaptiveDialogDescription" + +export const AdaptiveDialogFooter = ({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) => { + if (isIosBuild) { + return ( + + {children} + + ) + } + return {children} +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2fb575c..27d7840 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -22,7 +22,7 @@ const buttonVariants = cva( }, size: { default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", + sm: "h-10 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 5ccb7b9..8a107f2 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Drawer as DrawerPrimitive } from "vaul" import { cn } from "@/lib/util" +import { useKeyboardHeight } from "@/hooks/useKeyboardHeight" @@ -38,22 +39,26 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName const DrawerContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- {children} - - -)) +>(({ className, children, style, ...props }, ref) => { + const keyboardHeight = useKeyboardHeight(); + return ( + + + 0 ? keyboardHeight : undefined, ...style }} + {...props} + > +
+ {children} + + + ); +}) DrawerContent.displayName = "DrawerContent" const DrawerHeader = ({ diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 2cd7a67..0620ade 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -27,6 +27,8 @@ import { } from '@/utils/exportUtils'; import { parseTaskChecklist } from '@/utils/checklistUtils'; import { SCHEMA_VERSION } from '@/services/localStorageService'; +import { useAppLifecycle } from '@/hooks/useAppLifecycle'; +import { useHaptics } from '@/hooks/useHaptics'; export interface Task { id: string; @@ -229,6 +231,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const [lastSyncTime, setLastSyncTime] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const { successNotify, errorNotify } = useHaptics(); + // Debounce refs to manage timeouts const saveTimeoutRef = useRef(null); const currentTaskTimeoutRef = useRef(null); @@ -425,6 +429,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ "❌ Manual sync partially failed:", failed.map((f) => (f as PromiseRejectedResult).reason) ); + errorNotify(); // Do not mark sync as successful when any save failed return; } @@ -433,10 +438,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setHasUnsavedChanges(false); } catch (error) { console.error('❌ Manual sync failed:', error); + errorNotify(); } finally { setIsSyncing(false); } - }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems]); + }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems, errorNotify]); // Load current day data (for periodic sync) const loadCurrentDay = useCallback(async () => { @@ -480,27 +486,23 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [dataService, isDayStarted, tasks, currentTask, dayStartTime]); - // iOS/Capacitor apps don't reliably fire beforeunload when backgrounded or killed. - // visibilitychange fires when the app is suspended, giving us a last chance to - // write a synchronous backup before JavaScript execution is frozen. - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState !== "hidden") return; - if (!isDayStarted && tasks.length === 0) return; - try { - localStorage.setItem( - STORAGE_KEYS.CURRENT_DAY, - JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) - ); - } catch { - // best effort - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + // On iOS/Capacitor, useAppLifecycle fires at the Swift layer (appStateChange) + // before WKWebView is frozen — more reliable than beforeunload or visibilitychange. + // On web, it falls back to visibilitychange automatically. + const handleBackground = useCallback(() => { + if (!isDayStarted && tasks.length === 0) return; + try { + localStorage.setItem( + STORAGE_KEYS.CURRENT_DAY, + JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) + ); + } catch { + // best effort + } }, [isDayStarted, dayStartTime, tasks, currentTask]); + useAppLifecycle(handleBackground); + // Sync to backend when coming back online useEffect(() => { const handleOnline = () => { @@ -610,6 +612,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setTasks(updatedTasks); setCurrentTask(newTask); setHasUnsavedChanges(true); + successNotify(); // Save with freshly computed state to avoid reading from stale latestStateRef if (dataService) { dataService.saveCurrentDay({ isDayStarted, dayStartTime, currentTask: newTask, tasks: updatedTasks }) @@ -714,6 +717,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ } setHasUnsavedChanges(false); + successNotify(); // Show success notification to user toast({ diff --git a/src/hooks/useAppLifecycle.ts b/src/hooks/useAppLifecycle.ts new file mode 100644 index 0000000..a0e85d1 --- /dev/null +++ b/src/hooks/useAppLifecycle.ts @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Calls onBackground when the app is suspended (native) or hidden (web). + * On iOS/Capacitor, uses @capacitor/app's appStateChange which fires at the + * Swift layer before WKWebView freezes — more reliable than visibilitychange. + * Falls back to visibilitychange on web. + */ +export function useAppLifecycle(onBackground: () => void) { + useEffect(() => { + if (Capacitor.isNativePlatform()) { + // Dynamic import avoids a hard dependency when running as a PWA + // (the plugin is present but the runtime only activates on native) + import("@capacitor/app").then(({ App }) => { + const listenerPromise = App.addListener("appStateChange", ({ isActive }) => { + if (!isActive) { + onBackground(); + } + }); + return () => { + listenerPromise.then((handle) => handle.remove()); + }; + }); + } else { + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + onBackground(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + } + // onBackground is intentionally excluded — callers should memoize with useCallback + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/hooks/useHaptics.ts b/src/hooks/useHaptics.ts new file mode 100644 index 0000000..d604518 --- /dev/null +++ b/src/hooks/useHaptics.ts @@ -0,0 +1,51 @@ +import { Capacitor } from "@capacitor/core"; + +let hapticsModule: typeof import("@capacitor/haptics") | null = null; + +async function getHaptics() { + if (!Capacitor.isNativePlatform()) return null; + if (!hapticsModule) { + hapticsModule = await import("@capacitor/haptics"); + } + return hapticsModule; +} + +export function useHaptics() { + const lightImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Light }); + }); + }; + + const mediumImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Medium }); + }); + }; + + const heavyImpact = () => { + getHaptics().then((h) => { + if (h) h.Haptics.impact({ style: h.ImpactStyle.Heavy }); + }); + }; + + const successNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Success }); + }); + }; + + const errorNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Error }); + }); + }; + + const warnNotify = () => { + getHaptics().then((h) => { + if (h) h.Haptics.notification({ type: h.NotificationType.Warning }); + }); + }; + + return { lightImpact, mediumImpact, heavyImpact, successNotify, errorNotify, warnNotify }; +} diff --git a/src/hooks/useKeyboardHeight.ts b/src/hooks/useKeyboardHeight.ts new file mode 100644 index 0000000..6f8e0a4 --- /dev/null +++ b/src/hooks/useKeyboardHeight.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Returns the current on-screen keyboard height in px (0 when hidden). + * Uses @capacitor/keyboard events on native; always returns 0 on web. + */ +export function useKeyboardHeight(): number { + const [height, setHeight] = useState(0); + + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + + let showHandle: { remove: () => void } | null = null; + let hideHandle: { remove: () => void } | null = null; + + import("@capacitor/keyboard").then(({ Keyboard }) => { + Keyboard.addListener("keyboardWillShow", (info) => { + setHeight(info.keyboardHeight); + }).then((handle) => { showHandle = handle; }); + + Keyboard.addListener("keyboardWillHide", () => { + setHeight(0); + }).then((handle) => { hideHandle = handle; }); + }); + + return () => { + showHandle?.remove(); + hideHandle?.remove(); + }; + }, []); + + return height; +} diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts new file mode 100644 index 0000000..59f4992 --- /dev/null +++ b/src/hooks/useLongPress.ts @@ -0,0 +1,33 @@ +import { useRef, useCallback } from "react"; + +/** + * Returns pointer event handlers that fire `callback` after the pointer has + * been held down for `delay` ms without moving. Used to programmatically + * open a context menu on touch devices (where right-click is unavailable). + */ +export function useLongPress(callback: () => void, delay = 500) { + const timerRef = useRef | null>(null); + const cancelledRef = useRef(false); + + const start = useCallback(() => { + cancelledRef.current = false; + timerRef.current = setTimeout(() => { + if (!cancelledRef.current) callback(); + }, delay); + }, [callback, delay]); + + const cancel = useCallback(() => { + cancelledRef.current = true; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + return { + onPointerDown: start, + onPointerUp: cancel, + onPointerLeave: cancel, + onPointerCancel: cancel, + }; +} diff --git a/src/hooks/useStatusBar.ts b/src/hooks/useStatusBar.ts new file mode 100644 index 0000000..a368716 --- /dev/null +++ b/src/hooks/useStatusBar.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { Capacitor } from "@capacitor/core"; + +/** + * Syncs the iOS status bar text colour with the app's light/dark theme. + * Style.Dark = white text (for dark backgrounds). + * Style.Light = black text (for light backgrounds). + * No-op on web. + */ +export function useStatusBar(isDark: boolean) { + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + + import("@capacitor/status-bar").then(({ StatusBar, Style }) => { + StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }); + }); + }, [isDark]); +} diff --git a/src/main.tsx b/src/main.tsx index 495d50f..330557b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,10 @@ import App from './App.tsx'; import '@radix-ui/themes/styles.css'; import './index.css'; +if (import.meta.env.VITE_IOS_BUILD === "true") { + document.body.classList.add("ios-build"); +} + createRoot(document.getElementById('root')!).render( From ed131145ac2220ab427fdb98b64218e389f8635f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:08:32 +0000 Subject: [PATCH 2/5] docs: sync documentation for iOS Apple HIG improvements Update CHANGELOG.md, README.md, README-EXT.md, and CLAUDE.md to reflect the ten Apple HIG improvements landed in the previous commit: bottom sheets, haptics, app lifecycle persistence, status bar theming, iOS nav header, keyboard avoidance, long-press context menus, touch target fixes, scroll bounce, and page transitions. CLAUDE.md: bump to v2.3.0, expand Key Files table with new hooks and components, document installed Capacitor plugins, and extend the iOS "when adding new features" guidance with AdaptiveDialog and useHaptics usage patterns. https://claude.ai/code/session_014br3fcYa3SDmGjBHnDYZec --- CHANGELOG.md | 29 +++++++++++++++++++++++++++-- CLAUDE.md | 37 ++++++++++++++++++++++++++++++++----- README-EXT.md | 17 ++++++++++++++++- README.md | 2 +- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e135d9..e83465b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Dedicated Tasks page (`/tasks`) — task list, NewTaskForm, and End Day button moved out of the dashboard column into a standalone page; starting a day auto-navigates to `/tasks`; day summary displayed on the Tasks page after ending the day; dashboard shows a compact "Day In Progress" card with task count and a "View Tasks" link while a day is active - — `src/pages/TaskList.tsx` (new), `src/pages/Index.tsx`, `src/App.tsx` +- Apple HIG compliance pass for the native iOS app + - **Bottom sheets** — `TaskEditDialog`, `StartDayDialog`, `ArchiveEditDialog`, and `DeleteConfirmationDialog` now render as swipe-to-dismiss vaul `Drawer` sheets on iOS (snap points tuned per dialog complexity) and fall back to the existing centered Radix `Dialog` on web. `DeleteConfirmationDialog` reverses button order on iOS (destructive action above Cancel) per UIAlertController convention. + — `src/components/ui/adaptive-dialog.tsx` (new), `src/components/ui/drawer.tsx`, `src/components/TaskEditDialog.tsx`, `src/components/StartDayDialog.tsx`, `src/components/ArchiveEditDialog.tsx`, `src/components/DeleteConfirmationDialog.tsx` + - **Haptic feedback** — `useHaptics` wraps `@capacitor/haptics`; light impact on tab switches and Edit, medium on Delete, heavy on destructive confirm, success notification on task creation and day archive, error notification on sync failure. No-op on web. + — `src/hooks/useHaptics.ts` (new), `src/components/MobileNav.tsx`, `src/components/TaskItem.tsx`, `src/components/DeleteConfirmationDialog.tsx`, `src/contexts/TimeTrackingContext.tsx` + - **App lifecycle persistence** — `useAppLifecycle` uses `@capacitor/app`'s `appStateChange` event (fires at the Swift layer before WKWebView freezes) instead of `visibilitychange` for the emergency localStorage backup, eliminating the race condition on rapid app backgrounding. Falls back to `visibilitychange` on web. + — `src/hooks/useAppLifecycle.ts` (new), `src/contexts/TimeTrackingContext.tsx` + - **Status bar theming** — `useStatusBar` syncs the iOS status bar text colour (white in dark mode, black in light mode) via `@capacitor/status-bar`; `apple-mobile-web-app-status-bar-style` updated to `black-translucent` so the web view extends behind the status bar region. No-op on web. + — `src/hooks/useStatusBar.ts` (new), `src/App.tsx`, `index.html` + - **iOS navigation header** — desktop `SiteNavigationMenu` is hidden on iOS builds and replaced with `IosPageHeader`: a sticky 17px SF-style title bar with safe-area-inset-top padding, back chevron, and right-side action slot. `ios-build` class added to `` on iOS to prevent double-stacking of safe-area padding. + — `src/components/IosPageHeader.tsx` (new), `src/components/PageLayout.tsx`, `src/main.tsx`, `public/pwa.css` + - **Keyboard avoidance** — `@capacitor/keyboard` configured with `resize: body` so the viewport shrinks above the keyboard. `useKeyboardHeight` hook tracks keyboard height and applies it as `paddingBottom` on `DrawerContent` so form fields inside bottom sheets remain accessible. `scroll-margin-bottom: 24px` added for native scroll-into-view on input focus. + — `src/hooks/useKeyboardHeight.ts` (new), `capacitor.config.ts`, `src/components/ui/drawer.tsx`, `public/pwa.css` + - **Long-press context menus** — `useLongPress` fires a 500 ms hold callback; `TaskItem` wraps cards in a Radix `ContextMenu` (right-click on desktop, long-press on iOS) with Edit and Delete actions. Action buttons hidden on iOS builds where context menus serve as the primary affordance. + — `src/hooks/useLongPress.ts` (new), `src/components/TaskItem.tsx` + - **Page transition animations** — route changes in the iOS build play a subtle 280 ms slide-in from the right (`cubic-bezier(0.25, 0.46, 0.45, 0.94)`), scoped to `@supports (-webkit-touch-callout: none)` so the animation never runs on web. + — `src/App.tsx` (`AnimatedRoutes` component), `public/pwa.css` + - **New Capacitor plugins** installed: `@capacitor/app`, `@capacitor/haptics`, `@capacitor/status-bar`, `@capacitor/keyboard` (all v8.x, matching the existing core/ios versions). + +### Changed + +- **Touch targets** — `Button` `size="sm"` raised from `h-9` (36 px) to `h-10` (40 px); mobile CSS now enforces `min-height: 44px` on all non-hidden buttons at ≤768 px (previously commented out). + — `src/components/ui/button.tsx`, `public/pwa.css` +- **Rubber-band scroll bounce** — `overscroll-behavior-y` restored to `auto` on `#root` inside the iOS `@supports` block so the native bounce animation works again (was `contain` globally which suppressed it). vaul drawer elements gain `overscroll-behavior: contain` + `touch-action: pan-y` to prevent scroll bleed through open sheets. + — `public/pwa.css` + + - Tasks navigation item added to desktop top nav and mobile bottom nav; mobile nav grid updated to support up to five items for authenticated users — `src/components/Navigation.tsx`, `src/components/MobileNav.tsx` diff --git a/CLAUDE.md b/CLAUDE.md index 9cba0e5..4a8dbc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - AI Assistant Codebase Guide -**Last Updated:** 2026-04-26 -**Version:** 2.2.0 +**Last Updated:** 2026-05-14 +**Version:** 2.3.0 Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and consultants, with dual storage (localStorage guest mode and optional Supabase cloud sync). A native iOS app is also available via Capacitor. @@ -31,7 +31,7 @@ After implementing changes, run lint and tests before considering a task complet | Forms | React Hook Form + Zod | | Backend | Supabase (optional) or localStorage | | PWA | Vite PWA Plugin + Workbox | -| Native iOS | Capacitor 8 (@capacitor/core + @capacitor/ios) | +| Native iOS | Capacitor 8 (@capacitor/core + @capacitor/ios + @capacitor/app + @capacitor/haptics + @capacitor/status-bar + @capacitor/keyboard) | | Testing | Vitest + React Testing Library + Playwright | --- @@ -70,8 +70,15 @@ export const MyComponent = () => { | `src/lib/supabase.ts` | Supabase client configuration and caching | | `src/config/categories.ts` | Default category definitions | | `src/config/projects.ts` | Default project definitions | -| `src/components/PageLayout.tsx` | Shared page chrome (title + optional actions slot) | -| `capacitor.config.ts` | Capacitor iOS configuration | +| `src/components/PageLayout.tsx` | Shared page chrome (title + optional actions slot); renders `IosPageHeader` on iOS | +| `src/components/IosPageHeader.tsx` | iOS-only sticky nav bar with safe-area-inset-top, back chevron, and action slot | +| `src/components/ui/adaptive-dialog.tsx` | Renders vaul `Drawer` on iOS, Radix `Dialog` on web | +| `src/hooks/useHaptics.ts` | `@capacitor/haptics` wrapper (light/medium/heavy, success/error) | +| `src/hooks/useStatusBar.ts` | `@capacitor/status-bar` wrapper — syncs bar style with dark/light mode | +| `src/hooks/useAppLifecycle.ts` | `@capacitor/app` appStateChange hook for reliable background persistence | +| `src/hooks/useKeyboardHeight.ts` | `@capacitor/keyboard` reactive height for bottom-sheet form padding | +| `src/hooks/useLongPress.ts` | 500 ms hold detector for context menu trigger on touch | +| `capacitor.config.ts` | Capacitor iOS configuration (Keyboard resize plugin configured here) | | `.env.ios` | iOS build env (VITE_IOS_BUILD=true, no Supabase) | --- @@ -92,6 +99,23 @@ The app ships as both a PWA and a native iOS app via Capacitor 8. - Routing uses `HashRouter` (required — Capacitor loads from filesystem, not a server) - CSP includes `capacitor://localhost` for WKWebView asset loading - Data storage is localStorage-only (no Supabase keys in `.env.ios`) +- Desktop `SiteNavigationMenu` is hidden; `IosPageHeader` renders instead (sticky, safe-area-aware, back chevron) +- All edit/confirm dialogs (`TaskEditDialog`, `StartDayDialog`, `ArchiveEditDialog`, `DeleteConfirmationDialog`) become bottom sheets via `AdaptiveDialog`; on web the existing Radix Dialog renders unchanged +- Haptic feedback fires on every meaningful interaction via `useHaptics` +- `@capacitor/app` `appStateChange` event used for emergency data persistence (more reliable than `visibilitychange`) +- `@capacitor/status-bar` syncs status bar text colour with system dark/light mode +- `@capacitor/keyboard` configured with `resize: body`; `useKeyboardHeight` lifts bottom-sheet content above the keyboard +- Long-press on task cards opens a context menu (Edit / Delete); on-card action buttons are hidden + +**Installed Capacitor plugins** (all v8.x): + +| Package | Purpose | +| ------- | ------- | +| `@capacitor/core` + `@capacitor/ios` | Core bridge (pre-existing) | +| `@capacitor/app` | Native app lifecycle events (pause/resume) | +| `@capacitor/haptics` | Tactile feedback | +| `@capacitor/status-bar` | Status bar style control | +| `@capacitor/keyboard` | Keyboard height events and viewport resize | **iOS npm scripts:** @@ -114,6 +138,9 @@ When working on iOS/Capacitor projects, remember that `cap sync` overwrites Pack - Gate any web-only UI (PWA install, auth, sync) behind `import.meta.env.VITE_IOS_BUILD !== "true"` - Avoid `window.location.reload()` in iOS paths — use `window.location.replace()` to avoid interrupting the Capacitor JS bridge - Test localStorage-only flow (no Supabase) before marking iOS features complete +- For new dialogs/modals: use `AdaptiveDialog` (`src/components/ui/adaptive-dialog.tsx`) instead of `Dialog` directly — it automatically renders a bottom sheet on iOS +- Add haptic feedback for new interactions via `useHaptics` (`src/hooks/useHaptics.ts`): `lightImpact` for navigation/selection, `mediumImpact` for intent to delete, `heavyImpact` for confirmed destructive actions, `successNotify`/`errorNotify` for outcomes +- All new Capacitor plugin calls should be gated with `Capacitor.isNativePlatform()` or imported dynamically (see existing hooks for the pattern) so the web build never fails at runtime on missing native APIs --- diff --git a/README-EXT.md b/README-EXT.md index c356eb7..eac1af5 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -163,6 +163,21 @@ Task descriptions support **GitHub Flavored Markdown (GFM)**: **Native-Like Experience:** Standalone window, app icon, splash screen on launch. +### iOS Native App (Capacitor) + +The Capacitor build (`VITE_IOS_BUILD=true`) includes additional Apple HIG enhancements that are inactive in the PWA: + +| Feature | Detail | +| ------- | ------- | +| **Bottom sheets** | All edit/confirm dialogs slide up as swipe-to-dismiss sheets instead of centered overlays | +| **Haptic feedback** | Light impact on navigation taps, medium on destructive intent, success/error notifications on outcomes | +| **Status bar theming** | Status bar text colour tracks light/dark mode; content extends behind the status bar via `black-translucent` | +| **iOS navigation header** | Sticky 17 px title bar with safe-area-inset-top padding and back chevron replaces the desktop nav bar | +| **Keyboard avoidance** | Viewport shrinks above the software keyboard; bottom sheet forms scroll above it automatically | +| **Long-press context menus** | Hold a task card to reveal Edit / Delete without on-card buttons cluttering the layout | +| **Page transitions** | Subtle 280 ms slide-in animation on route changes, matching the iOS push-navigation idiom | +| **Rubber-band bounce** | Native scroll bounce restored on the main scroll container | + --- ## Authentication & Storage @@ -180,7 +195,7 @@ Timetraked uses an **action-triggered save** approach optimized for single-devic 1. **In-Memory First** — changes update React state immediately. 2. **Action Saves** — every task mutation (start, update, delete) and day lifecycle event (start day, end day) triggers an immediate `saveCurrentDay()` call with the freshly computed state, keeping localStorage and Supabase in sync without a debounce delay. -3. **Emergency Backups** — `visibilitychange` (iOS app backgrounding) and `beforeunload` (browser close) write a synchronous localStorage snapshot as a last-resort fallback before JavaScript execution is suspended. +3. **Emergency Backups** — on iOS, `@capacitor/app`'s `appStateChange` event fires at the Swift layer before WKWebView freezes, giving a reliable save window; on web, `visibilitychange` and `beforeunload` write a synchronous localStorage snapshot as a last-resort fallback before JavaScript execution is suspended. 4. **Manual Sync** — the sync button in the navigation saves all data types (tasks, projects, categories, archived days, todos) in one batch, useful after recovering from an error. When you sign in, your `localStorage` data automatically migrates to Supabase (timestamps compared to prevent overwriting newer data, no data loss). When you sign out, Supabase data syncs back to `localStorage`. diff --git a/README.md b/README.md index 95c3100..54af6db 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and - **CSV Import** — bring in existing time data from other tools - **Weekly Report** — AI-generated work summaries (standup, client, or retrospective tone) - **No Account Required** — full functionality with local storage; optional cloud sync via Supabase -- **PWA + Native iOS** — installable on desktop/mobile; distributed as a native iOS app via Capacitor 8 +- **PWA + Native iOS** — installable on desktop/mobile; distributed as a native iOS app via Capacitor 8 with Apple HIG-compliant bottom sheets, haptic feedback, status bar theming, keyboard avoidance, and native page transitions --- From af706fd1d77d66728040bdb88ccf2441c2688154 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:26:29 +0000 Subject: [PATCH 3/5] fix: resolve dialog close-button overlap and action-button layout issues - dialog.tsx: add pr-8 to DialogHeader so the absolute X button (right-4 top-4) never overlaps title text when DialogContent padding is narrow - StartDayDialog: remove extra px-4 from form content div that caused fields to be indented relative to the header and footer - TaskEditDialog: move Cancel/Save buttons into AdaptiveDialogFooter outside the overflow-y-auto scroll container so they are always visible regardless of scroll position; content area changed to flex-col with flex-1 min-h-0 overflow-y-auto inner wrapper https://claude.ai/code/session_014br3fcYa3SDmGjBHnDYZec --- src/components/StartDayDialog.tsx | 2 +- src/components/TaskEditDialog.tsx | 31 +++++++++++++++++-------------- src/components/ui/dialog.tsx | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/components/StartDayDialog.tsx b/src/components/StartDayDialog.tsx index fb1f8b1..7a39d5b 100644 --- a/src/components/StartDayDialog.tsx +++ b/src/components/StartDayDialog.tsx @@ -74,7 +74,7 @@ export const StartDayDialog: React.FC = ({ -
+
= ({ return ( - + @@ -214,6 +215,7 @@ export const TaskEditDialog: React.FC = ({ +
@@ -454,20 +456,21 @@ export const TaskEditDialog: React.FC = ({ - {/* Action Buttons */} -
- - -
+
+ + + + +
); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 775c0bd..37c5d8b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -60,7 +60,7 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (
Date: Thu, 14 May 2026 15:44:30 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20iOS=20layout=20=E2=80=94=20blank=20h?= =?UTF-8?q?eader,=20hidden=20FAB,=20and=20sheet=20snap=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Index.tsx: pass title/icon to PageLayout so IosPageHeader shows "Dashboard" in the center nav bar; remove the page's own inline h1 which was doubling the heading and leaving the iOS header blank - NewTaskForm: add fab-nav-offset CSS class so the floating + button clears the MobileNav (64px) plus safe-area-inset-bottom on iOS; was hidden behind the nav at the previous bottom-20 (80px) offset - adaptive-dialog: control vaul activeSnapPoint so drawers open at the first snap point immediately rather than requiring the user to swipe up from a near-zero peek height; resets to snapPoints[0] each time the dialog opens - pwa.css: add .fab-nav-offset rule under @supports iOS selector https://claude.ai/code/session_014br3fcYa3SDmGjBHnDYZec --- public/pwa.css | 7 +++++++ src/components/NewTaskForm.tsx | 2 +- src/components/ui/adaptive-dialog.tsx | 18 +++++++++++++++++- src/pages/Index.tsx | 10 +--------- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/public/pwa.css b/public/pwa.css index 7d42e26..571c34e 100644 --- a/public/pwa.css +++ b/public/pwa.css @@ -220,6 +220,13 @@ body { overscroll-behavior: contain; } +/* Lift the floating action button above the mobile nav + home indicator on iOS */ +@supports (-webkit-touch-callout: none) { + .fab-nav-offset { + bottom: calc(5rem + env(safe-area-inset-bottom, 0px)) !important; + } +} + /* iOS page transition: subtle slide-in from the right on route change */ @supports (-webkit-touch-callout: none) { .page-transition-enter { diff --git a/src/components/NewTaskForm.tsx b/src/components/NewTaskForm.tsx index 1eb7482..94b414c 100644 --- a/src/components/NewTaskForm.tsx +++ b/src/components/NewTaskForm.tsx @@ -68,7 +68,7 @@ export const NewTaskForm: React.FC = ({ onSubmit, defaultOpen