mirror of
https://github.com/jhbruhn/respira.git
synced 2026-03-13 18:28:41 +00:00
Compare commits
7 commits
60762d1526
...
a275f72311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a275f72311 | ||
| 38afe33826 | |||
| a6868ae5ec | |||
| bc46fe0015 | |||
| f2d05c2714 | |||
| 467eb9df95 | |||
| c81930d1b7 |
13 changed files with 693 additions and 934 deletions
422
src/App.tsx
422
src/App.tsx
|
|
@ -1,18 +1,13 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from './stores/useMachineStore';
|
||||
import { usePatternStore } from './stores/usePatternStore';
|
||||
import { useUIStore } from './stores/useUIStore';
|
||||
import { FileUpload } from './components/FileUpload';
|
||||
import { AppHeader } from './components/AppHeader';
|
||||
import { LeftSidebar } from './components/LeftSidebar';
|
||||
import { PatternCanvas } from './components/PatternCanvas';
|
||||
import { ProgressMonitor } from './components/ProgressMonitor';
|
||||
import { WorkflowStepper } from './components/WorkflowStepper';
|
||||
import { PatternSummaryCard } from './components/PatternSummaryCard';
|
||||
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
|
||||
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
|
||||
import { getErrorDetails } from './utils/errorCodeHelpers';
|
||||
import { getStateVisualInfo } from './utils/machineStateHelpers';
|
||||
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { isBluetoothSupported } from './utils/bluetoothSupport';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
|
|
@ -20,442 +15,69 @@ function App() {
|
|||
useEffect(() => {
|
||||
document.title = `Respira v${__APP_VERSION__}`;
|
||||
}, []);
|
||||
// Machine store
|
||||
|
||||
// Machine store - for auto-loading cached pattern
|
||||
const {
|
||||
isConnected,
|
||||
machineInfo,
|
||||
machineStatus,
|
||||
machineStatusName,
|
||||
machineError,
|
||||
patternInfo,
|
||||
error: machineErrorMessage,
|
||||
isPairingError,
|
||||
isCommunicating: isPolling,
|
||||
resumeFileName,
|
||||
resumedPattern,
|
||||
connect,
|
||||
disconnect,
|
||||
resumeFileName,
|
||||
} = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
isConnected: state.isConnected,
|
||||
machineInfo: state.machineInfo,
|
||||
machineStatus: state.machineStatus,
|
||||
machineStatusName: state.machineStatusName,
|
||||
machineError: state.machineError,
|
||||
patternInfo: state.patternInfo,
|
||||
error: state.error,
|
||||
isPairingError: state.isPairingError,
|
||||
isCommunicating: state.isCommunicating,
|
||||
resumeFileName: state.resumeFileName,
|
||||
resumedPattern: state.resumedPattern,
|
||||
connect: state.connect,
|
||||
disconnect: state.disconnect,
|
||||
resumeFileName: state.resumeFileName,
|
||||
}))
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
// Pattern store - for auto-loading cached pattern
|
||||
const {
|
||||
pesData,
|
||||
patternUploaded,
|
||||
setPattern,
|
||||
setPatternOffset,
|
||||
setPatternUploaded,
|
||||
} = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
patternUploaded: state.patternUploaded,
|
||||
setPattern: state.setPattern,
|
||||
setPatternOffset: state.setPatternOffset,
|
||||
setPatternUploaded: state.setPatternUploaded,
|
||||
}))
|
||||
);
|
||||
|
||||
// UI store
|
||||
// UI store - for Pyodide initialization
|
||||
const {
|
||||
pyodideError,
|
||||
showErrorPopover,
|
||||
initializePyodide,
|
||||
setErrorPopover,
|
||||
} = useUIStore(
|
||||
useShallow((state) => ({
|
||||
pyodideError: state.pyodideError,
|
||||
showErrorPopover: state.showErrorPopover,
|
||||
initializePyodide: state.initializePyodide,
|
||||
setErrorPopover: state.setErrorPopover,
|
||||
}))
|
||||
);
|
||||
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Initialize Pyodide in background on mount (non-blocking thanks to worker)
|
||||
useEffect(() => {
|
||||
initializePyodide();
|
||||
}, [initializePyodide]);
|
||||
|
||||
// Close error popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
errorPopoverRef.current &&
|
||||
!errorPopoverRef.current.contains(event.target as Node) &&
|
||||
errorButtonRef.current &&
|
||||
!errorButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setErrorPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showErrorPopover) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showErrorPopover, setErrorPopover]);
|
||||
|
||||
// Auto-load cached pattern when available
|
||||
if (resumedPattern && !pesData) {
|
||||
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
|
||||
setPattern(resumedPattern.pesData, resumeFileName || '');
|
||||
// Restore the cached pattern offset
|
||||
if (resumedPattern.patternOffset) {
|
||||
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
|
||||
useEffect(() => {
|
||||
if (resumedPattern && !pesData) {
|
||||
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
|
||||
setPattern(resumedPattern.pesData, resumeFileName || '');
|
||||
// Restore the cached pattern offset
|
||||
if (resumedPattern.patternOffset) {
|
||||
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Track pattern uploaded state based on machine status
|
||||
if (!isConnected) {
|
||||
if (patternUploaded) {
|
||||
setPatternUploaded(false);
|
||||
}
|
||||
} else {
|
||||
// Pattern is uploaded if machine has pattern info
|
||||
const shouldBeUploaded = patternInfo !== null;
|
||||
if (patternUploaded !== shouldBeUploaded) {
|
||||
setPatternUploaded(shouldBeUploaded);
|
||||
}
|
||||
}
|
||||
|
||||
// Get state visual info for header status badge
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
const stateIcons = {
|
||||
ready: CheckCircleIcon,
|
||||
active: BoltIcon,
|
||||
waiting: PauseCircleIcon,
|
||||
complete: CheckCircleIcon,
|
||||
interrupted: PauseCircleIcon,
|
||||
error: ExclamationTriangleIcon,
|
||||
};
|
||||
const StatusIcon = stateIcons[stateVisual.iconName];
|
||||
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden">
|
||||
<header className="bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 dark:from-blue-700 dark:via-blue-800 dark:to-blue-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-blue-900/20 dark:border-blue-800/30 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
||||
{/* Machine Connection Status - Responsive width column */}
|
||||
<div className="flex items-center gap-3 w-full lg:w-[280px]">
|
||||
<div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
|
||||
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
|
||||
{isConnected && machineInfo?.serialNumber && (
|
||||
<span
|
||||
className="text-xs text-blue-200 cursor-help"
|
||||
title={`Serial: ${machineInfo.serialNumber}${
|
||||
machineInfo.macAddress
|
||||
? `\nMAC: ${machineInfo.macAddress}`
|
||||
: ''
|
||||
}${
|
||||
machineInfo.totalCount !== undefined
|
||||
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
|
||||
: ''
|
||||
}${
|
||||
machineInfo.serviceCount !== undefined
|
||||
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
• {machineInfo.serialNumber}
|
||||
</span>
|
||||
)}
|
||||
{isPolling && (
|
||||
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<button
|
||||
onClick={disconnect}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0"
|
||||
title="Disconnect from machine"
|
||||
aria-label="Disconnect from machine"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{machineStatusName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-blue-200">Not Connected</p>
|
||||
)}
|
||||
|
||||
{/* Error indicator - always render to prevent layout shift */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={errorButtonRef}
|
||||
onClick={() => setErrorPopover(!showErrorPopover)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
|
||||
(machineErrorMessage || pyodideError)
|
||||
? 'cursor-pointer animate-pulse hover:animate-none'
|
||||
: 'invisible pointer-events-none'
|
||||
}`}
|
||||
title="Click to view error details"
|
||||
aria-label="View error details"
|
||||
disabled={!(machineErrorMessage || pyodideError)}
|
||||
>
|
||||
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{(() => {
|
||||
if (pyodideError) return 'Python Error';
|
||||
if (isPairingError) return 'Pairing Required';
|
||||
|
||||
const errorMsg = machineErrorMessage || '';
|
||||
|
||||
// Categorize by error message content
|
||||
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
|
||||
return 'Connection Error';
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('upload')) {
|
||||
return 'Upload Error';
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('pattern')) {
|
||||
return 'Pattern Error';
|
||||
}
|
||||
if (machineError !== undefined) {
|
||||
return `Machine Error`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'Error';
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Error popover */}
|
||||
{showErrorPopover && (machineErrorMessage || pyodideError) && (
|
||||
<div
|
||||
ref={errorPopoverRef}
|
||||
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-label="Error details"
|
||||
>
|
||||
{(() => {
|
||||
const errorDetails = getErrorDetails(machineError);
|
||||
const isPairingErr = isPairingError;
|
||||
const errorMsg = pyodideError || machineErrorMessage || '';
|
||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||
|
||||
const bgColor = isInfo
|
||||
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500'
|
||||
: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500';
|
||||
|
||||
const iconColor = isInfo
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
|
||||
const textColor = isInfo
|
||||
? 'text-blue-900 dark:text-blue-200'
|
||||
: 'text-red-900 dark:text-red-200';
|
||||
|
||||
const descColor = isInfo
|
||||
? 'text-blue-800 dark:text-blue-300'
|
||||
: 'text-red-800 dark:text-red-300';
|
||||
|
||||
const listColor = isInfo
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-red-700 dark:text-red-300';
|
||||
|
||||
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
||||
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
|
||||
|
||||
return (
|
||||
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={`text-sm ${descColor} mb-3`}>
|
||||
{errorDetails?.description || errorMsg}
|
||||
</p>
|
||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||
<>
|
||||
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
||||
{isInfo ? 'Steps:' : 'How to Fix:'}
|
||||
</h4>
|
||||
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
|
||||
{errorDetails.solutions.map((solution, index) => (
|
||||
<li key={index} className="pl-2">{solution}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
||||
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Stepper - Flexible width column */}
|
||||
<div>
|
||||
<WorkflowStepper />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
|
||||
{/* Left Column - Controls */}
|
||||
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
|
||||
{/* Connect Button or Browser Hint - Show when disconnected */}
|
||||
{!isConnected && (
|
||||
<>
|
||||
{isBluetoothSupported() ? (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={connect}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
|
||||
>
|
||||
Connect to Machine
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
|
||||
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
|
||||
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
|
||||
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
|
||||
<li>
|
||||
Download the Desktop app from{' '}
|
||||
<a
|
||||
href="https://github.com/jhbruhn/respira/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
|
||||
>
|
||||
GitHub Releases
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
|
||||
{isConnected && !patternUploaded && (
|
||||
<FileUpload />
|
||||
)}
|
||||
|
||||
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
|
||||
{isConnected && patternUploaded && pesData && (
|
||||
<PatternSummaryCard />
|
||||
)}
|
||||
|
||||
{/* Progress Monitor - Show when pattern is uploaded */}
|
||||
{isConnected && patternUploaded && (
|
||||
<div className="lg:flex-1 lg:min-h-0">
|
||||
<ProgressMonitor />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LeftSidebar />
|
||||
|
||||
{/* Right Column - Pattern Preview */}
|
||||
<div className="flex flex-col lg:overflow-hidden lg:h-full">
|
||||
{pesData ? (
|
||||
<PatternCanvas />
|
||||
) : (
|
||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
|
||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
|
||||
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
||||
{/* Decorative background pattern */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center relative z-10">
|
||||
<div className="relative inline-block mb-6">
|
||||
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
||||
Connect to your machine and choose a PES embroidery file to see your design preview
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></div>
|
||||
<span>Drag to Position</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-400 dark:bg-green-500 rounded-full"></div>
|
||||
<span>Zoom & Pan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-purple-400 dark:bg-purple-500 rounded-full"></div>
|
||||
<span>Real-time Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
207
src/components/AppHeader.tsx
Normal file
207
src/components/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { useUIStore } from '../stores/useUIStore';
|
||||
import { WorkflowStepper } from './WorkflowStepper';
|
||||
import { ErrorPopover } from './ErrorPopover';
|
||||
import { getStateVisualInfo } from '../utils/machineStateHelpers';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
BoltIcon,
|
||||
PauseCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
export function AppHeader() {
|
||||
const {
|
||||
isConnected,
|
||||
machineInfo,
|
||||
machineStatus,
|
||||
machineStatusName,
|
||||
machineError,
|
||||
error: machineErrorMessage,
|
||||
isPairingError,
|
||||
isCommunicating: isPolling,
|
||||
disconnect,
|
||||
} = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
isConnected: state.isConnected,
|
||||
machineInfo: state.machineInfo,
|
||||
machineStatus: state.machineStatus,
|
||||
machineStatusName: state.machineStatusName,
|
||||
machineError: state.machineError,
|
||||
error: state.error,
|
||||
isPairingError: state.isPairingError,
|
||||
isCommunicating: state.isCommunicating,
|
||||
disconnect: state.disconnect,
|
||||
}))
|
||||
);
|
||||
|
||||
const {
|
||||
pyodideError,
|
||||
showErrorPopover,
|
||||
setErrorPopover,
|
||||
} = useUIStore(
|
||||
useShallow((state) => ({
|
||||
pyodideError: state.pyodideError,
|
||||
showErrorPopover: state.showErrorPopover,
|
||||
setErrorPopover: state.setErrorPopover,
|
||||
}))
|
||||
);
|
||||
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Get state visual info for header status badge
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
const stateIcons = {
|
||||
ready: CheckCircleIcon,
|
||||
active: BoltIcon,
|
||||
waiting: PauseCircleIcon,
|
||||
complete: CheckCircleIcon,
|
||||
interrupted: PauseCircleIcon,
|
||||
error: ExclamationTriangleIcon,
|
||||
};
|
||||
const StatusIcon = stateIcons[stateVisual.iconName];
|
||||
|
||||
// Close error popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
errorPopoverRef.current &&
|
||||
!errorPopoverRef.current.contains(event.target as Node) &&
|
||||
errorButtonRef.current &&
|
||||
!errorButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setErrorPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showErrorPopover) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showErrorPopover, setErrorPopover]);
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 dark:from-blue-700 dark:via-blue-800 dark:to-blue-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-blue-900/20 dark:border-blue-800/30 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
||||
{/* Machine Connection Status - Responsive width column */}
|
||||
<div className="flex items-center gap-3 w-full lg:w-[280px]">
|
||||
<div className="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shadow-lg shadow-green-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
|
||||
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
|
||||
{isConnected && machineInfo?.serialNumber && (
|
||||
<span
|
||||
className="text-xs text-blue-200 cursor-help"
|
||||
title={`Serial: ${machineInfo.serialNumber}${
|
||||
machineInfo.macAddress
|
||||
? `\nMAC: ${machineInfo.macAddress}`
|
||||
: ''
|
||||
}${
|
||||
machineInfo.totalCount !== undefined
|
||||
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
|
||||
: ''
|
||||
}${
|
||||
machineInfo.serviceCount !== undefined
|
||||
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
• {machineInfo.serialNumber}
|
||||
</span>
|
||||
)}
|
||||
{isPolling && (
|
||||
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<button
|
||||
onClick={disconnect}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-red-600 text-blue-100 hover:text-white border border-white/20 hover:border-red-600 cursor-pointer transition-all flex-shrink-0"
|
||||
title="Disconnect from machine"
|
||||
aria-label="Disconnect from machine"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{machineStatusName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-blue-200">Not Connected</p>
|
||||
)}
|
||||
|
||||
{/* Error indicator - always render to prevent layout shift */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={errorButtonRef}
|
||||
onClick={() => setErrorPopover(!showErrorPopover)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
|
||||
(machineErrorMessage || pyodideError)
|
||||
? 'cursor-pointer animate-pulse hover:animate-none'
|
||||
: 'invisible pointer-events-none'
|
||||
}`}
|
||||
title="Click to view error details"
|
||||
aria-label="View error details"
|
||||
disabled={!(machineErrorMessage || pyodideError)}
|
||||
>
|
||||
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{(() => {
|
||||
if (pyodideError) return 'Python Error';
|
||||
if (isPairingError) return 'Pairing Required';
|
||||
|
||||
const errorMsg = machineErrorMessage || '';
|
||||
|
||||
// Categorize by error message content
|
||||
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
|
||||
return 'Connection Error';
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('upload')) {
|
||||
return 'Upload Error';
|
||||
}
|
||||
if (errorMsg.toLowerCase().includes('pattern')) {
|
||||
return 'Pattern Error';
|
||||
}
|
||||
if (machineError !== undefined) {
|
||||
return `Machine Error`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'Error';
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Error popover */}
|
||||
{showErrorPopover && (machineErrorMessage || pyodideError) && (
|
||||
<ErrorPopover
|
||||
ref={errorPopoverRef}
|
||||
machineError={machineError}
|
||||
isPairingError={isPairingError}
|
||||
errorMessage={machineErrorMessage}
|
||||
pyodideError={pyodideError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Stepper - Flexible width column */}
|
||||
<div>
|
||||
<WorkflowStepper />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
67
src/components/ConnectionPrompt.tsx
Normal file
67
src/components/ConnectionPrompt.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { isBluetoothSupported } from '../utils/bluetoothSupport';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
export function ConnectionPrompt() {
|
||||
const { connect } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
connect: state.connect,
|
||||
}))
|
||||
);
|
||||
|
||||
if (isBluetoothSupported()) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={connect}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
|
||||
>
|
||||
Connect to Machine
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
|
||||
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
|
||||
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
|
||||
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
|
||||
<li>
|
||||
Download the Desktop app from{' '}
|
||||
<a
|
||||
href="https://github.com/jhbruhn/respira/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
|
||||
>
|
||||
GitHub Releases
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/ErrorPopover.tsx
Normal file
84
src/components/ErrorPopover.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { getErrorDetails } from '../utils/errorCodeHelpers';
|
||||
|
||||
interface ErrorPopoverProps {
|
||||
machineError?: number;
|
||||
isPairingError: boolean;
|
||||
errorMessage?: string | null;
|
||||
pyodideError?: string | null;
|
||||
}
|
||||
|
||||
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
||||
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
|
||||
const errorDetails = getErrorDetails(machineError);
|
||||
const isPairingErr = isPairingError;
|
||||
const errorMsg = pyodideError || errorMessage || '';
|
||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||
|
||||
const bgColor = isInfo
|
||||
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500'
|
||||
: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500';
|
||||
|
||||
const iconColor = isInfo
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
|
||||
const textColor = isInfo
|
||||
? 'text-blue-900 dark:text-blue-200'
|
||||
: 'text-red-900 dark:text-red-200';
|
||||
|
||||
const descColor = isInfo
|
||||
? 'text-blue-800 dark:text-blue-300'
|
||||
: 'text-red-800 dark:text-red-300';
|
||||
|
||||
const listColor = isInfo
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-red-700 dark:text-red-300';
|
||||
|
||||
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
||||
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-label="Error details"
|
||||
>
|
||||
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={`text-sm ${descColor} mb-3`}>
|
||||
{errorDetails?.description || errorMsg}
|
||||
</p>
|
||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||
<>
|
||||
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
||||
{isInfo ? 'Steps:' : 'How to Fix:'}
|
||||
</h4>
|
||||
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
|
||||
{errorDetails.solutions.map((solution, index) => (
|
||||
<li key={index} className="pl-2">{solution}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
||||
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ErrorPopover.displayName = 'ErrorPopover';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { useUIStore } from '../stores/useUIStore';
|
||||
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
|
||||
|
|
@ -40,18 +40,19 @@ export function FileUpload() {
|
|||
pesData: pesDataProp,
|
||||
currentFileName,
|
||||
patternOffset,
|
||||
patternUploaded,
|
||||
setPattern,
|
||||
} = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
currentFileName: state.currentFileName,
|
||||
patternOffset: state.patternOffset,
|
||||
patternUploaded: state.patternUploaded,
|
||||
setPattern: state.setPattern,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
|
||||
// UI store
|
||||
const {
|
||||
pyodideReady,
|
||||
|
|
|
|||
44
src/components/LeftSidebar.tsx
Normal file
44
src/components/LeftSidebar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { ConnectionPrompt } from './ConnectionPrompt';
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { PatternSummaryCard } from './PatternSummaryCard';
|
||||
import { ProgressMonitor } from './ProgressMonitor';
|
||||
|
||||
export function LeftSidebar() {
|
||||
const { isConnected } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
isConnected: state.isConnected,
|
||||
}))
|
||||
);
|
||||
|
||||
const { pesData } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
|
||||
{/* Connect Button or Browser Hint - Show when disconnected */}
|
||||
{!isConnected && <ConnectionPrompt />}
|
||||
|
||||
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
|
||||
{isConnected && !patternUploaded && <FileUpload />}
|
||||
|
||||
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
|
||||
{isConnected && patternUploaded && pesData && <PatternSummaryCard />}
|
||||
|
||||
{/* Progress Monitor - Show when pattern is uploaded */}
|
||||
{isConnected && patternUploaded && (
|
||||
<div className="lg:flex-1 lg:min-h-0">
|
||||
<ProgressMonitor />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { Stage, Layer, Group } from 'react-konva';
|
||||
import Konva from 'konva';
|
||||
|
|
@ -27,16 +27,17 @@ export function PatternCanvas() {
|
|||
const {
|
||||
pesData,
|
||||
patternOffset: initialPatternOffset,
|
||||
patternUploaded,
|
||||
setPatternOffset,
|
||||
} = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
patternOffset: state.patternOffset,
|
||||
patternUploaded: state.patternUploaded,
|
||||
setPatternOffset: state.setPatternOffset,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
|
||||
|
|
|
|||
46
src/components/PatternPreviewPlaceholder.tsx
Normal file
46
src/components/PatternPreviewPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export function PatternPreviewPlaceholder() {
|
||||
return (
|
||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
|
||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
|
||||
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
||||
{/* Decorative background pattern */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center relative z-10">
|
||||
<div className="relative inline-block mb-6">
|
||||
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
||||
Connect to your machine and choose a PES embroidery file to see your design preview
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></div>
|
||||
<span>Drag to Position</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-400 dark:bg-green-500 rounded-full"></div>
|
||||
<span>Zoom & Pan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-purple-400 dark:bg-purple-500 rounded-full"></div>
|
||||
<span>Real-time Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
canResumeSewing,
|
||||
getStateVisualInfo,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { calculatePatternTime } from "../utils/timeCalculation";
|
||||
|
||||
export function ProgressMonitor() {
|
||||
// Machine store
|
||||
|
|
@ -56,8 +57,15 @@ export function ProgressMonitor() {
|
|||
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
|
||||
const progressPercent = patternInfo
|
||||
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
|
||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||
const totalStitches = patternInfo
|
||||
? (patternInfo.totalStitches === 0 && pesData?.penStitches
|
||||
? pesData.penStitches.stitches.length
|
||||
: patternInfo.totalStitches)
|
||||
: 0;
|
||||
|
||||
const progressPercent = totalStitches > 0
|
||||
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
|
||||
: 0;
|
||||
|
||||
// Calculate color block information from decoded penStitches
|
||||
|
|
@ -102,6 +110,15 @@ export function ProgressMonitor() {
|
|||
currentStitch >= block.startStitch && currentStitch < block.endStitch,
|
||||
);
|
||||
|
||||
// Calculate time based on color blocks (matches Brother app calculation)
|
||||
const { totalMinutes, elapsedMinutes } = useMemo(() => {
|
||||
if (colorBlocks.length === 0) {
|
||||
return { totalMinutes: 0, elapsedMinutes: 0 };
|
||||
}
|
||||
const result = calculatePatternTime(colorBlocks, currentStitch);
|
||||
return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes };
|
||||
}, [colorBlocks, currentStitch]);
|
||||
|
||||
// Auto-scroll to current block
|
||||
useEffect(() => {
|
||||
if (currentBlockRef.current) {
|
||||
|
|
@ -173,16 +190,15 @@ export function ProgressMonitor() {
|
|||
Total Stitches
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{patternInfo.totalStitches.toLocaleString()}
|
||||
{totalStitches.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Est. Time
|
||||
Total Time
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{Math.floor(patternInfo.totalTime / 60)}:
|
||||
{String(patternInfo.totalTime % 60).padStart(2, "0")}
|
||||
{totalMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||
|
|
@ -213,16 +229,15 @@ export function ProgressMonitor() {
|
|||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{sewingProgress.currentStitch.toLocaleString()} /{" "}
|
||||
{patternInfo?.totalStitches.toLocaleString() || 0}
|
||||
{totalStitches.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Time Elapsed
|
||||
Time
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{Math.floor(sewingProgress.currentTime / 60)}:
|
||||
{String(sewingProgress.currentTime % 60).padStart(2, "0")}
|
||||
{elapsedMinutes} / {totalMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMachineStore } from '../stores/useMachineStore';
|
||||
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
|
||||
import { usePatternStore } from '../stores/usePatternStore';
|
||||
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
|
||||
import { MachineStatus } from '../types/machine';
|
||||
|
|
@ -268,14 +268,14 @@ export function WorkflowStepper() {
|
|||
// Pattern store
|
||||
const {
|
||||
pesData,
|
||||
patternUploaded,
|
||||
} = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
patternUploaded: state.patternUploaded,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const hasPattern = pesData !== null;
|
||||
const hasErrorFlag = hasError(machineError);
|
||||
const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded);
|
||||
|
|
|
|||
|
|
@ -1,457 +0,0 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { BrotherPP1Service, BluetoothPairingError } from "../services/BrotherPP1Service";
|
||||
import type {
|
||||
MachineInfo,
|
||||
PatternInfo,
|
||||
SewingProgress,
|
||||
} from "../types/machine";
|
||||
import { MachineStatus, MachineStatusNames } from "../types/machine";
|
||||
import {
|
||||
uuidToString,
|
||||
} from "../services/PatternCacheService";
|
||||
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
||||
import { createStorageService } from "../platform";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { SewingMachineError } from "../utils/errorCodeHelpers";
|
||||
|
||||
export function useBrotherMachine() {
|
||||
const [service] = useState(() => new BrotherPP1Service());
|
||||
const [storageService] = useState<IStorageService>(() => createStorageService());
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [machineInfo, setMachineInfo] = useState<MachineInfo | null>(null);
|
||||
const [machineStatus, setMachineStatus] = useState<MachineStatus>(
|
||||
MachineStatus.None,
|
||||
);
|
||||
const [machineError, setMachineError] = useState<number>(SewingMachineError.None);
|
||||
const [patternInfo, setPatternInfo] = useState<PatternInfo | null>(null);
|
||||
const [sewingProgress, setSewingProgress] = useState<SewingProgress | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPairingError, setIsPairingError] = useState(false);
|
||||
const [isCommunicating, setIsCommunicating] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [resumeAvailable, setResumeAvailable] = useState(false);
|
||||
const [resumeFileName, setResumeFileName] = useState<string | null>(null);
|
||||
const [resumedPattern, setResumedPattern] = useState<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Subscribe to service communication state
|
||||
useEffect(() => {
|
||||
const unsubscribe = service.onCommunicationChange(setIsCommunicating);
|
||||
return unsubscribe;
|
||||
}, [service]);
|
||||
|
||||
// Subscribe to disconnect events
|
||||
useEffect(() => {
|
||||
const unsubscribe = service.onDisconnect(() => {
|
||||
console.log('[useBrotherMachine] Device disconnected');
|
||||
setIsConnected(false);
|
||||
setMachineInfo(null);
|
||||
setMachineStatus(MachineStatus.None);
|
||||
setMachineError(SewingMachineError.None);
|
||||
setPatternInfo(null);
|
||||
setSewingProgress(null);
|
||||
setError('Device disconnected');
|
||||
setIsPairingError(false);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [service]);
|
||||
|
||||
// Define checkResume first (before connect uses it)
|
||||
const checkResume = useCallback(async (): Promise<PesPatternData | null> => {
|
||||
try {
|
||||
console.log("[Resume] Checking for cached pattern...");
|
||||
|
||||
// Get UUID from machine
|
||||
const machineUuid = await service.getPatternUUID();
|
||||
|
||||
console.log(
|
||||
"[Resume] Machine UUID:",
|
||||
machineUuid ? uuidToString(machineUuid) : "none",
|
||||
);
|
||||
|
||||
if (!machineUuid) {
|
||||
console.log("[Resume] No pattern loaded on machine");
|
||||
setResumeAvailable(false);
|
||||
setResumeFileName(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have this pattern cached
|
||||
const uuidStr = uuidToString(machineUuid);
|
||||
const cached = await storageService.getPatternByUUID(uuidStr);
|
||||
|
||||
if (cached) {
|
||||
console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset);
|
||||
console.log("[Resume] Auto-loading cached pattern...");
|
||||
setResumeAvailable(true);
|
||||
setResumeFileName(cached.fileName);
|
||||
setResumedPattern({ pesData: cached.pesData, patternOffset: cached.patternOffset });
|
||||
|
||||
// Fetch pattern info from machine
|
||||
try {
|
||||
const info = await service.getPatternInfo();
|
||||
setPatternInfo(info);
|
||||
console.log("[Resume] Pattern info loaded from machine");
|
||||
} catch (err) {
|
||||
console.error("[Resume] Failed to load pattern info:", err);
|
||||
}
|
||||
|
||||
// Return the cached pattern data to be loaded
|
||||
return cached.pesData;
|
||||
} else {
|
||||
console.log("[Resume] Pattern on machine not found in cache");
|
||||
setResumeAvailable(false);
|
||||
setResumeFileName(null);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Resume] Failed to check resume:", err);
|
||||
setResumeAvailable(false);
|
||||
setResumeFileName(null);
|
||||
return null;
|
||||
}
|
||||
}, [service, storageService]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsPairingError(false);
|
||||
await service.connect();
|
||||
setIsConnected(true);
|
||||
|
||||
// Fetch initial machine info and status
|
||||
const info = await service.getMachineInfo();
|
||||
setMachineInfo(info);
|
||||
|
||||
const state = await service.getMachineState();
|
||||
setMachineStatus(state.status);
|
||||
setMachineError(state.error);
|
||||
|
||||
// Check for resume possibility
|
||||
await checkResume();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
const isPairing = err instanceof BluetoothPairingError;
|
||||
setIsPairingError(isPairing);
|
||||
setError(err instanceof Error ? err.message : "Failed to connect");
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [service, checkResume]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
await service.disconnect();
|
||||
setIsConnected(false);
|
||||
setMachineInfo(null);
|
||||
setMachineStatus(MachineStatus.None);
|
||||
setPatternInfo(null);
|
||||
setSewingProgress(null);
|
||||
setError(null);
|
||||
setMachineError(SewingMachineError.None);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to disconnect");
|
||||
}
|
||||
}, [service]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
const state = await service.getMachineState();
|
||||
setMachineStatus(state.status);
|
||||
setMachineError(state.error);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to get status");
|
||||
}
|
||||
}, [service, isConnected]);
|
||||
|
||||
const refreshPatternInfo = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
const info = await service.getPatternInfo();
|
||||
setPatternInfo(info);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to get pattern info",
|
||||
);
|
||||
}
|
||||
}, [service, isConnected]);
|
||||
|
||||
const refreshProgress = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
const progress = await service.getSewingProgress();
|
||||
setSewingProgress(progress);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to get progress");
|
||||
}
|
||||
}, [service, isConnected]);
|
||||
|
||||
const refreshServiceCount = useCallback(async () => {
|
||||
if (!isConnected || !machineInfo) return;
|
||||
|
||||
try {
|
||||
const counts = await service.getServiceCount();
|
||||
setMachineInfo({
|
||||
...machineInfo,
|
||||
serviceCount: counts.serviceCount,
|
||||
totalCount: counts.totalCount,
|
||||
});
|
||||
} catch (err) {
|
||||
// Don't set error for service count failures - it's not critical
|
||||
console.warn("Failed to get service count:", err);
|
||||
}
|
||||
}, [service, isConnected, machineInfo]);
|
||||
|
||||
const loadCachedPattern =
|
||||
useCallback(async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
|
||||
if (!resumeAvailable) return null;
|
||||
|
||||
try {
|
||||
const machineUuid = await service.getPatternUUID();
|
||||
if (!machineUuid) return null;
|
||||
|
||||
const uuidStr = uuidToString(machineUuid);
|
||||
const cached = await storageService.getPatternByUUID(uuidStr);
|
||||
|
||||
if (cached) {
|
||||
console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset);
|
||||
// Refresh pattern info from machine
|
||||
await refreshPatternInfo();
|
||||
return { pesData: cached.pesData, patternOffset: cached.patternOffset };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load cached pattern",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [service, storageService, resumeAvailable, refreshPatternInfo]);
|
||||
|
||||
const uploadPattern = useCallback(
|
||||
async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => {
|
||||
if (!isConnected) {
|
||||
setError("Not connected to machine");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
setIsUploading(true); // Set loading state immediately
|
||||
const uuid = await service.uploadPattern(
|
||||
penData,
|
||||
(progress) => {
|
||||
setUploadProgress(progress);
|
||||
},
|
||||
pesData.bounds,
|
||||
patternOffset,
|
||||
);
|
||||
setUploadProgress(100);
|
||||
|
||||
// Cache the pattern with its UUID and offset
|
||||
const uuidStr = uuidToString(uuid);
|
||||
storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
|
||||
console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset);
|
||||
|
||||
// Clear resume state since we just uploaded
|
||||
setResumeAvailable(false);
|
||||
setResumeFileName(null);
|
||||
|
||||
// Refresh status after upload
|
||||
// NOTE: We don't call refreshPatternInfo() here because the machine hasn't
|
||||
// finished processing the pattern yet. Pattern info (stitch count, time estimate)
|
||||
// is only available AFTER startMaskTrace() is called.
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to upload pattern",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false); // Clear loading state
|
||||
}
|
||||
},
|
||||
[service, storageService, isConnected, refreshStatus],
|
||||
);
|
||||
|
||||
const startMaskTrace = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await service.startMaskTrace();
|
||||
|
||||
// After mask trace, poll machine status a few times to ensure it's ready
|
||||
// The machine needs time to process the pattern before pattern info is accurate
|
||||
console.log('[MaskTrace] Polling machine status...');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await refreshStatus();
|
||||
}
|
||||
|
||||
// Now the machine should have accurate pattern info
|
||||
console.log('[MaskTrace] Refreshing pattern info...');
|
||||
await refreshPatternInfo();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to start mask trace",
|
||||
);
|
||||
}
|
||||
}, [service, isConnected, refreshStatus, refreshPatternInfo]);
|
||||
|
||||
const startSewing = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await service.startSewing();
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start sewing");
|
||||
}
|
||||
}, [service, isConnected, refreshStatus]);
|
||||
|
||||
const resumeSewing = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await service.resumeSewing();
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to resume sewing");
|
||||
}
|
||||
}, [service, isConnected, refreshStatus]);
|
||||
|
||||
const deletePattern = useCallback(async () => {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setIsDeleting(true); // Set loading state immediately
|
||||
|
||||
// Delete pattern from cache to prevent auto-resume
|
||||
try {
|
||||
const machineUuid = await service.getPatternUUID();
|
||||
if (machineUuid) {
|
||||
const uuidStr = uuidToString(machineUuid);
|
||||
await storageService.deletePattern(uuidStr);
|
||||
console.log("[Cache] Deleted pattern with UUID:", uuidStr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Cache] Failed to get UUID for cache deletion:", err);
|
||||
}
|
||||
|
||||
await service.deletePattern();
|
||||
|
||||
// Clear machine-related state but keep pattern data in UI for re-editing
|
||||
setPatternInfo(null);
|
||||
setSewingProgress(null);
|
||||
setUploadProgress(0); // Reset upload progress to allow new uploads
|
||||
setResumeAvailable(false);
|
||||
setResumeFileName(null);
|
||||
// NOTE: We intentionally DON'T clear setResumedPattern(null)
|
||||
// so the pattern remains visible in the canvas for re-editing
|
||||
// However, we DO need to preserve pesData in App.tsx for re-upload
|
||||
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete pattern");
|
||||
} finally {
|
||||
setIsDeleting(false); // Clear loading state
|
||||
}
|
||||
}, [service, storageService, isConnected, refreshStatus]);
|
||||
|
||||
// Periodic status monitoring when connected
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine polling interval based on machine status
|
||||
let pollInterval = 2000; // Default: 2 seconds for idle states
|
||||
|
||||
// Fast polling for active states
|
||||
if (
|
||||
machineStatus === MachineStatus.SEWING ||
|
||||
machineStatus === MachineStatus.MASK_TRACING ||
|
||||
machineStatus === MachineStatus.SEWING_DATA_RECEIVE
|
||||
) {
|
||||
pollInterval = 500; // 500ms for active operations
|
||||
} else if (
|
||||
machineStatus === MachineStatus.COLOR_CHANGE_WAIT ||
|
||||
machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT ||
|
||||
machineStatus === MachineStatus.SEWING_WAIT
|
||||
) {
|
||||
pollInterval = 1000; // 1 second for waiting states
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
await refreshStatus();
|
||||
|
||||
// Refresh progress during sewing
|
||||
if (machineStatus === MachineStatus.SEWING) {
|
||||
await refreshProgress();
|
||||
}
|
||||
}, pollInterval);
|
||||
|
||||
// Separate interval for service count (slower update rate - every 10 seconds)
|
||||
const serviceCountInterval = setInterval(async () => {
|
||||
await refreshServiceCount();
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearInterval(serviceCountInterval);
|
||||
};
|
||||
}, [isConnected, machineStatus, refreshStatus, refreshProgress, refreshServiceCount]);
|
||||
|
||||
// Refresh pattern info when status changes to SEWING_WAIT
|
||||
// (indicates pattern was just uploaded or is ready)
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
if (machineStatus === MachineStatus.SEWING_WAIT && !patternInfo) {
|
||||
refreshPatternInfo();
|
||||
}
|
||||
}, [isConnected, machineStatus, patternInfo, refreshPatternInfo]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
machineInfo,
|
||||
machineStatus,
|
||||
machineStatusName: MachineStatusNames[machineStatus] || "Unknown",
|
||||
machineError,
|
||||
patternInfo,
|
||||
sewingProgress,
|
||||
uploadProgress,
|
||||
error,
|
||||
isPairingError,
|
||||
isPolling: isCommunicating,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
resumeAvailable,
|
||||
resumeFileName,
|
||||
resumedPattern,
|
||||
connect,
|
||||
disconnect,
|
||||
refreshStatus,
|
||||
refreshPatternInfo,
|
||||
uploadPattern,
|
||||
startMaskTrace,
|
||||
startSewing,
|
||||
resumeSewing,
|
||||
deletePattern,
|
||||
checkResume,
|
||||
loadCachedPattern,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import { create } from 'zustand';
|
||||
import { BrotherPP1Service, BluetoothPairingError } from '../services/BrotherPP1Service';
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
BrotherPP1Service,
|
||||
BluetoothPairingError,
|
||||
} from "../services/BrotherPP1Service";
|
||||
import type {
|
||||
MachineInfo,
|
||||
PatternInfo,
|
||||
SewingProgress,
|
||||
} from '../types/machine';
|
||||
import { MachineStatus, MachineStatusNames } from '../types/machine';
|
||||
import { SewingMachineError } from '../utils/errorCodeHelpers';
|
||||
import { uuidToString } from '../services/PatternCacheService';
|
||||
import { createStorageService } from '../platform';
|
||||
import type { IStorageService } from '../platform/interfaces/IStorageService';
|
||||
import type { PesPatternData } from '../formats/import/pesImporter';
|
||||
} from "../types/machine";
|
||||
import { MachineStatus, MachineStatusNames } from "../types/machine";
|
||||
import { SewingMachineError } from "../utils/errorCodeHelpers";
|
||||
import { uuidToString } from "../services/PatternCacheService";
|
||||
import { createStorageService } from "../platform";
|
||||
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
interface MachineState {
|
||||
// Service instances
|
||||
|
|
@ -37,7 +40,10 @@ interface MachineState {
|
|||
// Resume state
|
||||
resumeAvailable: boolean;
|
||||
resumeFileName: string | null;
|
||||
resumedPattern: { pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null;
|
||||
resumedPattern: {
|
||||
pesData: PesPatternData;
|
||||
patternOffset?: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
// Error state
|
||||
error: string | null;
|
||||
|
|
@ -62,14 +68,17 @@ interface MachineState {
|
|||
penData: Uint8Array,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
) => Promise<void>;
|
||||
startMaskTrace: () => Promise<void>;
|
||||
startSewing: () => Promise<void>;
|
||||
resumeSewing: () => Promise<void>;
|
||||
deletePattern: () => Promise<void>;
|
||||
checkResume: () => Promise<PesPatternData | null>;
|
||||
loadCachedPattern: () => Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>;
|
||||
loadCachedPattern: () => Promise<{
|
||||
pesData: PesPatternData;
|
||||
patternOffset?: { x: number; y: number };
|
||||
} | null>;
|
||||
|
||||
// Internal methods
|
||||
_setupSubscriptions: () => void;
|
||||
|
|
@ -84,7 +93,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
isConnected: false,
|
||||
machineInfo: null,
|
||||
machineStatus: MachineStatus.None,
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown",
|
||||
machineError: SewingMachineError.None,
|
||||
patternInfo: null,
|
||||
sewingProgress: null,
|
||||
|
|
@ -104,16 +113,16 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
checkResume: async (): Promise<PesPatternData | null> => {
|
||||
try {
|
||||
const { service, storageService } = get();
|
||||
console.log('[Resume] Checking for cached pattern...');
|
||||
console.log("[Resume] Checking for cached pattern...");
|
||||
|
||||
const machineUuid = await service.getPatternUUID();
|
||||
console.log(
|
||||
'[Resume] Machine UUID:',
|
||||
machineUuid ? uuidToString(machineUuid) : 'none',
|
||||
"[Resume] Machine UUID:",
|
||||
machineUuid ? uuidToString(machineUuid) : "none",
|
||||
);
|
||||
|
||||
if (!machineUuid) {
|
||||
console.log('[Resume] No pattern loaded on machine');
|
||||
console.log("[Resume] No pattern loaded on machine");
|
||||
set({ resumeAvailable: false, resumeFileName: null });
|
||||
return null;
|
||||
}
|
||||
|
|
@ -122,31 +131,39 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
const cached = await storageService.getPatternByUUID(uuidStr);
|
||||
|
||||
if (cached) {
|
||||
console.log('[Resume] Pattern found in cache:', cached.fileName, 'Offset:', cached.patternOffset);
|
||||
console.log('[Resume] Auto-loading cached pattern...');
|
||||
console.log(
|
||||
"[Resume] Pattern found in cache:",
|
||||
cached.fileName,
|
||||
"Offset:",
|
||||
cached.patternOffset,
|
||||
);
|
||||
console.log("[Resume] Auto-loading cached pattern...");
|
||||
set({
|
||||
resumeAvailable: true,
|
||||
resumeFileName: cached.fileName,
|
||||
resumedPattern: { pesData: cached.pesData, patternOffset: cached.patternOffset },
|
||||
resumedPattern: {
|
||||
pesData: cached.pesData,
|
||||
patternOffset: cached.patternOffset,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch pattern info from machine
|
||||
try {
|
||||
const info = await service.getPatternInfo();
|
||||
set({ patternInfo: info });
|
||||
console.log('[Resume] Pattern info loaded from machine');
|
||||
console.log("[Resume] Pattern info loaded from machine");
|
||||
} catch (err) {
|
||||
console.error('[Resume] Failed to load pattern info:', err);
|
||||
console.error("[Resume] Failed to load pattern info:", err);
|
||||
}
|
||||
|
||||
return cached.pesData;
|
||||
} else {
|
||||
console.log('[Resume] Pattern on machine not found in cache');
|
||||
console.log("[Resume] Pattern on machine not found in cache");
|
||||
set({ resumeAvailable: false, resumeFileName: null });
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Resume] Failed to check resume:', err);
|
||||
console.error("[Resume] Failed to check resume:", err);
|
||||
set({ resumeAvailable: false, resumeFileName: null });
|
||||
return null;
|
||||
}
|
||||
|
|
@ -168,7 +185,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
set({
|
||||
machineInfo: info,
|
||||
machineStatus: state.status,
|
||||
machineStatusName: MachineStatusNames[state.status] || 'Unknown',
|
||||
machineStatusName: MachineStatusNames[state.status] || "Unknown",
|
||||
machineError: state.error,
|
||||
});
|
||||
|
||||
|
|
@ -182,7 +199,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
const isPairing = err instanceof BluetoothPairingError;
|
||||
set({
|
||||
isPairingError: isPairing,
|
||||
error: err instanceof Error ? err.message : 'Failed to connect',
|
||||
error: err instanceof Error ? err.message : "Failed to connect",
|
||||
isConnected: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -199,7 +216,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
isConnected: false,
|
||||
machineInfo: null,
|
||||
machineStatus: MachineStatus.None,
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown",
|
||||
patternInfo: null,
|
||||
sewingProgress: null,
|
||||
error: null,
|
||||
|
|
@ -207,7 +224,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to disconnect',
|
||||
error: err instanceof Error ? err.message : "Failed to disconnect",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -221,12 +238,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
const state = await service.getMachineState();
|
||||
set({
|
||||
machineStatus: state.status,
|
||||
machineStatusName: MachineStatusNames[state.status] || 'Unknown',
|
||||
machineStatusName: MachineStatusNames[state.status] || "Unknown",
|
||||
machineError: state.error,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to get status',
|
||||
error: err instanceof Error ? err.message : "Failed to get status",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -241,7 +258,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
set({ patternInfo: info });
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to get pattern info',
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to get pattern info",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -256,7 +274,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
set({ sewingProgress: progress });
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to get progress',
|
||||
error: err instanceof Error ? err.message : "Failed to get progress",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -276,7 +294,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to get service count:', err);
|
||||
console.warn("Failed to get service count:", err);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -285,11 +303,17 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
penData: Uint8Array,
|
||||
pesData: PesPatternData,
|
||||
fileName: string,
|
||||
patternOffset?: { x: number; y: number }
|
||||
patternOffset?: { x: number; y: number },
|
||||
) => {
|
||||
const { isConnected, service, storageService, refreshStatus, refreshPatternInfo } = get();
|
||||
const {
|
||||
isConnected,
|
||||
service,
|
||||
storageService,
|
||||
refreshStatus,
|
||||
refreshPatternInfo,
|
||||
} = get();
|
||||
if (!isConnected) {
|
||||
set({ error: 'Not connected to machine' });
|
||||
set({ error: "Not connected to machine" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +334,14 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
// Cache the pattern with its UUID and offset
|
||||
const uuidStr = uuidToString(uuid);
|
||||
storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
|
||||
console.log('[Cache] Saved pattern:', fileName, 'with UUID:', uuidStr, 'Offset:', patternOffset);
|
||||
console.log(
|
||||
"[Cache] Saved pattern:",
|
||||
fileName,
|
||||
"with UUID:",
|
||||
uuidStr,
|
||||
"Offset:",
|
||||
patternOffset,
|
||||
);
|
||||
|
||||
// Clear resume state since we just uploaded
|
||||
set({
|
||||
|
|
@ -323,7 +354,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshPatternInfo();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to upload pattern',
|
||||
error: err instanceof Error ? err.message : "Failed to upload pattern",
|
||||
});
|
||||
} finally {
|
||||
set({ isUploading: false });
|
||||
|
|
@ -341,7 +372,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshStatus();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to start mask trace',
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to start mask trace",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -357,7 +389,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshStatus();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to start sewing',
|
||||
error: err instanceof Error ? err.message : "Failed to start sewing",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -373,7 +405,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshStatus();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to resume sewing',
|
||||
error: err instanceof Error ? err.message : "Failed to resume sewing",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -392,10 +424,10 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
if (machineUuid) {
|
||||
const uuidStr = uuidToString(machineUuid);
|
||||
await storageService.deletePattern(uuidStr);
|
||||
console.log('[Cache] Deleted pattern with UUID:', uuidStr);
|
||||
console.log("[Cache] Deleted pattern with UUID:", uuidStr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Cache] Failed to get UUID for cache deletion:', err);
|
||||
console.warn("[Cache] Failed to get UUID for cache deletion:", err);
|
||||
}
|
||||
|
||||
await service.deletePattern();
|
||||
|
|
@ -412,7 +444,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshStatus();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to delete pattern',
|
||||
error: err instanceof Error ? err.message : "Failed to delete pattern",
|
||||
});
|
||||
} finally {
|
||||
set({ isDeleting: false });
|
||||
|
|
@ -420,8 +452,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
},
|
||||
|
||||
// Load cached pattern
|
||||
loadCachedPattern: async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
|
||||
const { resumeAvailable, service, storageService, refreshPatternInfo } = get();
|
||||
loadCachedPattern: async (): Promise<{
|
||||
pesData: PesPatternData;
|
||||
patternOffset?: { x: number; y: number };
|
||||
} | null> => {
|
||||
const { resumeAvailable, service, storageService, refreshPatternInfo } =
|
||||
get();
|
||||
if (!resumeAvailable) return null;
|
||||
|
||||
try {
|
||||
|
|
@ -432,7 +468,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
const cached = await storageService.getPatternByUUID(uuidStr);
|
||||
|
||||
if (cached) {
|
||||
console.log('[Resume] Loading cached pattern:', cached.fileName, 'Offset:', cached.patternOffset);
|
||||
console.log(
|
||||
"[Resume] Loading cached pattern:",
|
||||
cached.fileName,
|
||||
"Offset:",
|
||||
cached.patternOffset,
|
||||
);
|
||||
await refreshPatternInfo();
|
||||
return { pesData: cached.pesData, patternOffset: cached.patternOffset };
|
||||
}
|
||||
|
|
@ -440,7 +481,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
return null;
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to load cached pattern',
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to load cached pattern",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
|
@ -457,17 +499,17 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
|
||||
// Subscribe to disconnect events
|
||||
service.onDisconnect(() => {
|
||||
console.log('[useMachineStore] Device disconnected');
|
||||
console.log("[useMachineStore] Device disconnected");
|
||||
get()._stopPolling();
|
||||
set({
|
||||
isConnected: false,
|
||||
machineInfo: null,
|
||||
machineStatus: MachineStatus.None,
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || 'Unknown',
|
||||
machineStatusName: MachineStatusNames[MachineStatus.None] || "Unknown",
|
||||
machineError: SewingMachineError.None,
|
||||
patternInfo: null,
|
||||
sewingProgress: null,
|
||||
error: 'Device disconnected',
|
||||
error: "Device disconnected",
|
||||
isPairingError: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -475,7 +517,13 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
|
||||
// Start polling for status updates
|
||||
_startPolling: () => {
|
||||
const { _stopPolling, refreshStatus, refreshProgress, refreshServiceCount } = get();
|
||||
const {
|
||||
_stopPolling,
|
||||
refreshStatus,
|
||||
refreshProgress,
|
||||
refreshServiceCount,
|
||||
refreshPatternInfo,
|
||||
} = get();
|
||||
|
||||
// Stop any existing polling
|
||||
_stopPolling();
|
||||
|
|
@ -510,6 +558,11 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
await refreshProgress();
|
||||
}
|
||||
|
||||
// follows the apps logic:
|
||||
if (get().resumeAvailable && get().patternInfo?.totalStitches == 0) {
|
||||
await refreshPatternInfo();
|
||||
}
|
||||
|
||||
// Schedule next poll with updated interval
|
||||
const newInterval = getPollInterval();
|
||||
const pollIntervalId = setTimeout(poll, newInterval);
|
||||
|
|
@ -546,9 +599,18 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
|||
useMachineStore.getState()._setupSubscriptions();
|
||||
|
||||
// Selector hooks for common use cases
|
||||
export const useIsConnected = () => useMachineStore((state) => state.isConnected);
|
||||
export const useMachineInfo = () => useMachineStore((state) => state.machineInfo);
|
||||
export const useMachineStatus = () => useMachineStore((state) => state.machineStatus);
|
||||
export const useMachineError = () => useMachineStore((state) => state.machineError);
|
||||
export const usePatternInfo = () => useMachineStore((state) => state.patternInfo);
|
||||
export const useSewingProgress = () => useMachineStore((state) => state.sewingProgress);
|
||||
export const useIsConnected = () =>
|
||||
useMachineStore((state) => state.isConnected);
|
||||
export const useMachineInfo = () =>
|
||||
useMachineStore((state) => state.machineInfo);
|
||||
export const useMachineStatus = () =>
|
||||
useMachineStore((state) => state.machineStatus);
|
||||
export const useMachineError = () =>
|
||||
useMachineStore((state) => state.machineError);
|
||||
export const usePatternInfo = () =>
|
||||
useMachineStore((state) => state.patternInfo);
|
||||
export const useSewingProgress = () =>
|
||||
useMachineStore((state) => state.sewingProgress);
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
export const usePatternUploaded = () =>
|
||||
useMachineStore((state) => state.patternInfo !== null);
|
||||
|
|
|
|||
67
src/utils/timeCalculation.ts
Normal file
67
src/utils/timeCalculation.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Convert stitch count to minutes using Brother PP1 timing formula
|
||||
* Formula: ((pointCount - 1) * 150 + 3000) / 60000
|
||||
* - 150ms per stitch
|
||||
* - 3000ms startup time
|
||||
* - Result in minutes (rounded up)
|
||||
*/
|
||||
export function convertStitchesToMinutes(stitchCount: number): number {
|
||||
if (stitchCount <= 1) return 0;
|
||||
|
||||
const timeMs = (stitchCount - 1) * 150 + 3000;
|
||||
const timeMin = Math.ceil(timeMs / 60000);
|
||||
|
||||
return timeMin < 1 ? 1 : timeMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total and elapsed time for a pattern based on color blocks
|
||||
* This matches the Brother app's calculation method
|
||||
*/
|
||||
export function calculatePatternTime(
|
||||
colorBlocks: Array<{ stitchCount: number }>,
|
||||
currentStitch: number
|
||||
): {
|
||||
totalMinutes: number;
|
||||
elapsedMinutes: number;
|
||||
remainingMinutes: number;
|
||||
} {
|
||||
let totalMinutes = 0;
|
||||
let elapsedMinutes = 0;
|
||||
let cumulativeStitches = 0;
|
||||
|
||||
// Calculate time per color block
|
||||
for (const block of colorBlocks) {
|
||||
totalMinutes += convertStitchesToMinutes(block.stitchCount);
|
||||
cumulativeStitches += block.stitchCount;
|
||||
|
||||
if (cumulativeStitches < currentStitch) {
|
||||
// This entire block is completed
|
||||
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
|
||||
} else if (cumulativeStitches === currentStitch) {
|
||||
// We just completed this block
|
||||
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
|
||||
break;
|
||||
} else {
|
||||
// We're partway through this block
|
||||
const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount);
|
||||
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMinutes,
|
||||
elapsedMinutes,
|
||||
remainingMinutes: Math.max(0, totalMinutes - elapsedMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format minutes as MM:SS
|
||||
*/
|
||||
export function formatMinutes(minutes: number): string {
|
||||
const mins = Math.floor(minutes);
|
||||
const secs = Math.round((minutes - mins) * 60);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
Loading…
Reference in a new issue