├── .gitignore ├── .prettierignore ├── .prettierrc ├── App.tsx ├── LICENSE ├── README.md ├── app-env.d.ts ├── app.json ├── assets ├── CC.png ├── adaptive-icon.png ├── favicon.png ├── icon.png └── splash.png ├── babel.config.js ├── cesconfig.jsonc ├── eslint.config.js ├── global.css ├── metro.config.js ├── nativewind-env.d.ts ├── package-lock.json ├── package.json ├── prettier.config.js ├── privacy_policy.txt ├── screenshots └── HEADER.jpg ├── src ├── components │ ├── Header.tsx │ ├── PrinterCard.tsx │ ├── icons │ │ └── PrinterIcon.tsx │ └── index.ts ├── contexts │ ├── PrinterConnectionsContext.tsx │ └── ThemeContext.tsx ├── navigation │ └── AppNavigator.tsx ├── screens │ ├── AddEditPrinterScreen.tsx │ ├── HomeScreen.tsx │ ├── PrinterDetailsScreen.tsx │ ├── SettingsScreen.tsx │ ├── WelcomeScreen.tsx │ ├── WhereIsIpScreen.tsx │ └── index.ts ├── types │ └── index.ts └── utils │ ├── FormatUtils.ts │ └── LocalNetworkUtils.ts ├── tailwind.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.* 4 | yarn-debug.* 5 | yarn-error.* 6 | pnpm-debug.* 7 | lerna-debug.log* 8 | 9 | # Expo 10 | .expo/ 11 | .expo-shared/ 12 | dist/ 13 | web-build/ 14 | 15 | # Native build outputs 16 | ios/ 17 | android/ 18 | *.jks 19 | *.p8 20 | *.p12 21 | *.key 22 | *.mobileprovision 23 | *.orig.* 24 | *.ipa 25 | *.apk 26 | *.aab 27 | 28 | # Environment variables 29 | .env 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # IDE and Editor files 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *.swo 40 | *~ 41 | 42 | # OS generated files 43 | .DS_Store 44 | .DS_Store? 45 | ._* 46 | .Spotlight-V100 47 | .Trashes 48 | ehthumbs.db 49 | Thumbs.db 50 | 51 | # Logs 52 | logs 53 | *.log 54 | npm-debug.log* 55 | yarn-debug.log* 56 | yarn-error.log* 57 | lerna-debug.log* 58 | .pnpm-debug.log* 59 | 60 | # Runtime data 61 | pids 62 | *.pid 63 | *.seed 64 | *.pid.lock 65 | 66 | # Coverage directory used by tools like istanbul 67 | coverage/ 68 | *.lcov 69 | 70 | # nyc test coverage 71 | .nyc_output 72 | 73 | # Dependency directories 74 | jspm_packages/ 75 | 76 | # Optional npm cache directory 77 | .npm 78 | 79 | # Optional eslint cache 80 | .eslintcache 81 | 82 | # Optional stylelint cache 83 | .stylelintcache 84 | 85 | # Microbundle cache 86 | .rpt2_cache/ 87 | .rts2_cache_cjs/ 88 | .rts2_cache_es/ 89 | .rts2_cache_umd/ 90 | 91 | # Optional REPL history 92 | .node_repl_history 93 | 94 | # Output of 'npm pack' 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | .yarn-integrity 99 | 100 | # parcel-bundler cache (https://parceljs.org/) 101 | .cache 102 | .parcel-cache 103 | 104 | # Next.js build output 105 | .next 106 | 107 | # Nuxt.js build / generate output 108 | .nuxt 109 | 110 | # Gatsby files 111 | .cache/ 112 | public 113 | 114 | # Storybook build outputs 115 | .out 116 | .storybook-out 117 | 118 | # Temporary folders 119 | tmp/ 120 | temp/ 121 | 122 | # Metro 123 | .metro-health-check* 124 | 125 | # React Native 126 | *.hprof 127 | 128 | # Flipper 129 | ios/Pods/ 130 | 131 | # Bundle artifacts 132 | *.jsbundle 133 | 134 | # CocoaPods 135 | /ios/Pods/ 136 | 137 | # Fastlane 138 | fastlane/report.xml 139 | fastlane/Preview.html 140 | fastlane/screenshots 141 | fastlane/test_output 142 | 143 | # Bundle artifacts 144 | *.jsbundle 145 | 146 | # Ruby / CocoaPods 147 | /ios/Pods/ 148 | /vendor/bundle/ 149 | 150 | # Temporary files created by Metro to check the health of the file watcher 151 | .metro-health-check* 152 | 153 | # Testing 154 | /coverage 155 | 156 | # Production 157 | /build 158 | 159 | # Misc 160 | *.tgz 161 | *.tar.gz 162 | 163 | # Local Netlify folder 164 | .netlify 165 | 166 | # Vercel 167 | .vercel 168 | 169 | # TypeScript 170 | *.tsbuildinfo 171 | 172 | # Optional npm cache directory 173 | .npm 174 | 175 | # Optional eslint cache 176 | .eslintcache 177 | 178 | # Stores VSCode versions used for testing VSCode extensions 179 | .vscode-test 180 | 181 | # yarn v2 182 | .yarn/cache 183 | .yarn/unplugged 184 | .yarn/build-state.yml 185 | .yarn/install-state.gz 186 | .pnp.* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build outputs 8 | android/ 9 | ios/ 10 | build/ 11 | dist/ 12 | *.apk 13 | *.aab 14 | *.ipa 15 | 16 | # Expo 17 | .expo/ 18 | .expo-shared/ 19 | 20 | # Environment files 21 | .env 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | # IDE 28 | .vscode/ 29 | .idea/ 30 | *.swp 31 | *.swo 32 | 33 | # OS 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Logs 38 | *.log 39 | 40 | # Runtime data 41 | pids 42 | *.pid 43 | *.seed 44 | *.pid.lock 45 | 46 | # Coverage directory used by tools like istanbul 47 | coverage/ 48 | 49 | # Temporary folders 50 | tmp/ 51 | temp/ 52 | 53 | # Package files 54 | package-lock.json 55 | yarn.lock 56 | 57 | # Generated files 58 | *.generated.* 59 | *.min.js 60 | *.min.css 61 | 62 | # Config files that should maintain their format 63 | *.json 64 | *.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "endOfLine": "lf" 12 | } -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Platform } from 'react-native'; 3 | import { StatusBar } from 'expo-status-bar'; 4 | import { AppNavigator } from './src/navigation/AppNavigator'; 5 | import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; 6 | import { PrinterConnectionsProvider } from './src/contexts/PrinterConnectionsContext'; 7 | import WelcomeScreen from './src/screens/WelcomeScreen'; 8 | import AsyncStorage from '@react-native-async-storage/async-storage'; 9 | import { 10 | NavigationContainer, 11 | DarkTheme, 12 | DefaultTheme, 13 | } from '@react-navigation/native'; 14 | import * as NavigationBar from 'expo-navigation-bar'; 15 | 16 | import './global.css'; 17 | 18 | function AppContent() { 19 | const { colorScheme } = useTheme(); 20 | 21 | const MyDarkTheme = { 22 | ...DarkTheme, 23 | colors: { 24 | ...DarkTheme.colors, 25 | background: '#111827', 26 | card: '#1f2937', 27 | text: '#f9fafb', 28 | border: '#374151', 29 | }, 30 | }; 31 | 32 | const MyLightTheme = { 33 | ...DefaultTheme, 34 | colors: { 35 | ...DefaultTheme.colors, 36 | background: '#f1f5f9', 37 | card: '#ffffff', 38 | text: '#111827', 39 | border: '#e5e7eb', 40 | }, 41 | }; 42 | 43 | useEffect(() => { 44 | if (Platform.OS === 'android') { 45 | const barColor = colorScheme === 'dark' ? '#111827' : '#f1f5f9'; 46 | NavigationBar.setBackgroundColorAsync(barColor); 47 | NavigationBar.setButtonStyleAsync( 48 | colorScheme === 'dark' ? 'light' : 'dark' 49 | ); 50 | } 51 | }, [colorScheme]); 52 | 53 | return ( 54 | 57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | function AppLogic() { 64 | const [showWelcome, setShowWelcome] = useState(false); 65 | const [isLoading, setIsLoading] = useState(true); 66 | 67 | useEffect(() => { 68 | const checkFirstLaunch = async () => { 69 | try { 70 | const hasLaunched = await AsyncStorage.getItem('hasLaunched'); 71 | if (hasLaunched === null) { 72 | setShowWelcome(true); 73 | } 74 | } catch (error) { 75 | console.error('Failed to load launch status:', error); 76 | } finally { 77 | setIsLoading(false); 78 | } 79 | }; 80 | 81 | checkFirstLaunch(); 82 | }, []); 83 | 84 | const handleWelcomeComplete = async () => { 85 | try { 86 | await AsyncStorage.setItem('hasLaunched', 'true'); 87 | setShowWelcome(false); 88 | } catch (error) { 89 | console.error('Failed to save launch status:', error); 90 | } 91 | }; 92 | 93 | if (isLoading) { 94 | return null; // or a loading spinner 95 | } 96 | 97 | if (showWelcome) { 98 | return ; 99 | } 100 | 101 | return ( 102 | 103 | 104 | 105 | ); 106 | } 107 | 108 | export default function App() { 109 | return ( 110 | 111 | 112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 FREDERICK 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CC Tool Mobile 2 | 3 | Welcome to CC Tool! 4 | 5 | ![Screenshot 1](screenshots/HEADER.jpg) 6 | 7 | ## 📄 License 8 | 9 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /app-env.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /// 3 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "CC Tool", 4 | "slug": "cc-tool", 5 | "plugins": [ 6 | [ 7 | "expo-build-properties", 8 | { 9 | "android": { 10 | "usesCleartextTraffic": true 11 | } 12 | } 13 | ] 14 | ], 15 | "version": "1.0.5", 16 | "web": { 17 | "favicon": "./assets/favicon.png" 18 | }, 19 | "experiments": { 20 | "tsconfigPaths": true 21 | }, 22 | "orientation": "portrait", 23 | "icon": "./assets/icon.png", 24 | "userInterfaceStyle": "automatic", 25 | "splash": { 26 | "image": "./assets/splash.png", 27 | "resizeMode": "contain", 28 | "backgroundColor": "#ffffff" 29 | }, 30 | "assetBundlePatterns": [ 31 | "**/*" 32 | ], 33 | "ios": { 34 | "supportsTablet": true, 35 | "bundleIdentifier": "dev.allisonf.cctool", 36 | "infoPlist": { 37 | "NSLocalNetworkUsageDescription": "This app needs to access your local network to connect to your 3D printers.", 38 | "NSBonjourServices": ["_bonjour._tcp", "_lnp._tcp."] 39 | } 40 | }, 41 | "android": { 42 | "softwareKeyboardLayoutMode": "pan", 43 | "adaptiveIcon": { 44 | "foregroundImage": "./assets/adaptive-icon.png", 45 | "backgroundColor": "#ffffff" 46 | }, 47 | "package": "dev.allisonf.cctool", 48 | "versionCode": 7 49 | }, 50 | "developmentClient": { 51 | "silenceDeploymentWarnings": true 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /assets/CC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/assets/CC.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/assets/favicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | let plugins = []; 4 | 5 | return { 6 | presets: [ 7 | ['babel-preset-expo', { jsxImportSource: 'nativewind' }], 8 | 'nativewind/babel', 9 | ], 10 | 11 | plugins, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /cesconfig.jsonc: -------------------------------------------------------------------------------- 1 | // This is an optional configuration file used primarily for debugging purposes when reporting issues. 2 | // It is safe to delete this file as it does not affect the functionality of your application. 3 | { 4 | "cesVersion": "2.18.6", 5 | "projectName": "my-expo-app", 6 | "packages": [ 7 | { 8 | "name": "nativewind", 9 | "type": "styling" 10 | } 11 | ], 12 | "flags": { 13 | "noGit": false, 14 | "noInstall": false, 15 | "overwrite": false, 16 | "importAlias": true, 17 | "packageManager": "npm", 18 | "eas": false, 19 | "publish": false 20 | }, 21 | "packageManager": { 22 | "type": "npm", 23 | "version": "10.8.2" 24 | }, 25 | "os": { 26 | "type": "Darwin", 27 | "platform": "darwin", 28 | "arch": "arm64", 29 | "kernelVersion": "24.5.0" 30 | } 31 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const { defineConfig } = require('eslint/config'); 3 | const expoConfig = require('eslint-config-expo/flat'); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ['dist/*'], 9 | }, 10 | { 11 | rules: { 12 | 'react/display-name': 'off', 13 | }, 14 | }, 15 | ]); 16 | -------------------------------------------------------------------------------- /global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require('expo/metro-config'); 2 | const { withNativeWind } = require('nativewind/metro'); 3 | 4 | const config = getDefaultConfig(__dirname); 5 | 6 | module.exports = withNativeWind(config, { input: './global.css' }); 7 | -------------------------------------------------------------------------------- /nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cc-tool", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "android": "expo start --android --dev-client", 7 | "ios": "expo start --ios --dev-client", 8 | "web": "expo start --web --dev-client", 9 | "start": "expo start --dev-client", 10 | "prebuild": "expo prebuild", 11 | "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"", 12 | "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", 13 | "prettier": "prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", 14 | "prettier:check": "prettier \"**/*.{js,jsx,ts,tsx,json}\" --check" 15 | }, 16 | "dependencies": { 17 | "@expo/metro-runtime": "~5.0.4", 18 | "@expo/vector-icons": "^14.1.0", 19 | "@generac/react-native-local-network-permission": "^1.2.0", 20 | "@react-native-async-storage/async-storage": "^2.2.0", 21 | "@react-navigation/bottom-tabs": "^7.4.2", 22 | "@react-navigation/native": "^7.1.14", 23 | "@react-navigation/stack": "^7.4.2", 24 | "expo": "^53.0.19", 25 | "expo-build-properties": "~0.14.8", 26 | "expo-dev-client": "^5.2.4", 27 | "expo-navigation-bar": "~4.2.7", 28 | "expo-status-bar": "~2.2.3", 29 | "nativewind": "latest", 30 | "react": "19.0.0", 31 | "react-dom": "19.0.0", 32 | "react-native": "0.79.5", 33 | "react-native-gesture-handler": "^2.27.1", 34 | "react-native-pager-view": "^6.8.1", 35 | "react-native-progress": "^5.0.1", 36 | "react-native-reanimated": "~3.17.4", 37 | "react-native-safe-area-context": "^5.4.0", 38 | "react-native-screens": "^4.12.0", 39 | "react-native-svg": "15.11.2", 40 | "react-native-web": "^0.20.0", 41 | "react-native-webview": "^13.15.0", 42 | "uuid": "^11.1.0", 43 | "yup": "^1.6.1" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.20.0", 47 | "@types/react": "~19.0.10", 48 | "eslint": "^9.25.1", 49 | "eslint-config-expo": "^9.2.0", 50 | "eslint-config-prettier": "^10.1.2", 51 | "prettier": "^3.6.2", 52 | "prettier-plugin-tailwindcss": "^0.5.11", 53 | "tailwindcss": "^3.4.0", 54 | "typescript": "~5.8.3" 55 | }, 56 | "main": "node_modules/expo/AppEntry.js", 57 | "private": true 58 | } 59 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | bracketSameLine: true, 6 | trailingComma: 'es5', 7 | 8 | plugins: [require.resolve('prettier-plugin-tailwindcss')], 9 | tailwindAttributes: ['className'], 10 | }; 11 | -------------------------------------------------------------------------------- /privacy_policy.txt: -------------------------------------------------------------------------------- 1 | CC Tool does not collect any personal data. -------------------------------------------------------------------------------- /screenshots/HEADER.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WalkerFrederick/CC-Tool/b25ec35c0ed50a74f33c4d8952cb75e314641aa6/screenshots/HEADER.jpg -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, TouchableOpacity, Image } from 'react-native'; 3 | import { Ionicons } from '@expo/vector-icons'; 4 | import '../../global.css'; 5 | import { useTheme } from '../contexts/ThemeContext'; 6 | 7 | interface HeaderProps { 8 | title: string; 9 | subtitle?: string; 10 | leftAction?: { 11 | icon: string; 12 | onPress: () => void; 13 | }; 14 | rightAction?: { 15 | icon: string; 16 | onPress: () => void; 17 | }; 18 | className?: string; 19 | } 20 | 21 | export const Header: React.FC = ({ 22 | title, 23 | subtitle, 24 | rightAction, 25 | className = '', 26 | }) => { 27 | const { colorScheme } = useTheme(); 28 | return ( 29 | 30 | 31 | 36 | {/* Title Section with Logo - Centered */} 37 | 38 | 39 | 40 | CC Tool 41 | 42 | {subtitle && ( 43 | 44 | {title} 45 | 46 | )} 47 | 48 | 49 | {/* Right Action */} 50 | 51 | {rightAction ? ( 52 | 56 | 61 | 62 | ) : ( 63 | 64 | )} 65 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/PrinterCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { View, Text, TouchableOpacity } from 'react-native'; 3 | import { Ionicons } from '@expo/vector-icons'; 4 | import * as Progress from 'react-native-progress'; 5 | import { 6 | Printer, 7 | ConnectionStatus, 8 | getPrintStatus, 9 | isPausable, 10 | isResumable, 11 | isStoppable, 12 | } from '../types'; 13 | import { 14 | formatTimeAgo, 15 | formatTextMaxEllipsis, 16 | formatTicksToReadableTime, 17 | } from '../utils/FormatUtils'; 18 | 19 | interface PrinterCardProps { 20 | printer: Printer; 21 | onPress?: () => void; 22 | showTemps?: boolean; 23 | lastUpdate?: string; 24 | printStatus?: 'printing' | 'paused'; 25 | onPrintControl?: () => void; 26 | onStopPrint?: () => void; 27 | showPrintControls?: boolean; 28 | } 29 | 30 | export const PrinterCard = ({ 31 | printer, 32 | onPress, 33 | showTemps = true, 34 | lastUpdate, 35 | printStatus, 36 | onPrintControl, 37 | onStopPrint, 38 | showPrintControls = false, 39 | }: PrinterCardProps) => { 40 | const [currentTime, setCurrentTime] = useState(Date.now()); 41 | 42 | // Update time every second to refresh the "time ago" display 43 | useEffect(() => { 44 | const timer = setInterval(() => { 45 | setCurrentTime(Date.now()); 46 | }, 1000); 47 | 48 | return () => clearInterval(timer); 49 | }, []); 50 | 51 | const statusColors: { [key in ConnectionStatus]: string } = { 52 | connected: 'text-emerald-500', 53 | connecting: 'text-yellow-500', 54 | disconnected: 'text-gray-500', 55 | error: 'text-red-500', 56 | timeout: 'text-red-500', 57 | }; 58 | 59 | // Get real status data 60 | const status = printer.status; 61 | const bedTemp = status?.TempOfHotbed || 0; 62 | const nozzleTemp = status?.TempOfNozzle || 0; 63 | const boxTemp = status?.TempOfBox || 0; 64 | const targetBedTemp = status?.TempTargetHotbed || 0; 65 | const targetNozzleTemp = status?.TempTargetNozzle || 0; 66 | const targetBoxTemp = status?.TempTargetBox || 0; 67 | const printInfo = status?.PrintInfo; 68 | const currentLayer = printInfo?.CurrentLayer || 0; 69 | const totalLayer = printInfo?.TotalLayer || 0; 70 | const progress = printInfo?.Progress || 0; 71 | const printStatusFromPrinter = printInfo?.Status || 0; 72 | const filename = printInfo?.Filename || ''; 73 | 74 | const currentTicks = printInfo?.CurrentTicks || 0; 75 | const totalTicks = printInfo?.TotalTicks || 0; 76 | 77 | // Get human-readable print status 78 | const readablePrintStatus = getPrintStatus(printStatusFromPrinter); 79 | 80 | // Determine action availability based on print status 81 | const canPause = isPausable(printStatusFromPrinter); 82 | const canResume = isResumable(printStatusFromPrinter); 83 | const canStop = isStoppable(printStatusFromPrinter); 84 | 85 | // Determine if printer is actually printing based on status 86 | const isPrinting = readablePrintStatus === 'printing'; 87 | const isConnected = printer.connectionStatus === 'connected'; 88 | 89 | return ( 90 | 95 | 96 | 97 | 98 | 99 | {formatTextMaxEllipsis(printer.printerName, 20)} 100 | 101 | {onPress && isConnected ? ( 102 | 103 | {printer.lastUpdate && ( 104 | 105 | {formatTimeAgo(printer.lastUpdate, currentTime)} 106 | 107 | )} 108 | 109 | 110 | ) : ( 111 | printer.lastUpdate && ( 112 | 113 | Last update: {formatTimeAgo(printer.lastUpdate, currentTime)} 114 | 115 | ) 116 | )} 117 | 118 | 119 | 120 | {formatTextMaxEllipsis(printer.ipAddress, 30)} 121 | 122 | 125 | {printer.connectionStatus.toUpperCase()} 126 | 127 | 128 | 129 | 130 | 131 | {isConnected && ( 132 | <> 133 | {showTemps && ( 134 | 135 | 136 | 137 | {nozzleTemp.toFixed(1)}°C 138 | 139 | 140 | {targetNozzleTemp.toFixed(1)}°C 141 | 142 | 143 | NOZZLE 144 | 145 | 146 | 147 | 148 | {bedTemp.toFixed(1)}°C 149 | 150 | 151 | {targetBedTemp.toFixed(1)}°C 152 | 153 | 154 | BED 155 | 156 | 157 | 158 | 159 | {boxTemp.toFixed(1)}°C 160 | 161 | 162 | {targetBoxTemp.toFixed(1)}°C 163 | 164 | 165 | CHAMBER 166 | 167 | 168 | 169 | )} 170 | {readablePrintStatus === 'idle' ? ( 171 | <> 172 | ) : ( 173 | 174 | {isPrinting ? ( 175 | <> 176 | {filename && ( 177 | 178 | {formatTextMaxEllipsis(filename, 25)} 179 | 180 | )} 181 | {totalLayer > 0 && ( 182 | 183 | 184 | Layer {currentLayer}/{totalLayer} 185 | 186 | 187 | ~ {formatTicksToReadableTime(totalTicks - currentTicks)}{' '} 188 | remaining 189 | 190 | 191 | )} 192 | 201 | 202 | {progress}% Complete 203 | 204 | 205 | ) : ( 206 | <> 207 | 208 | {readablePrintStatus === 'completed' 209 | ? 'Print Completed' 210 | : readablePrintStatus === 'preparing' 211 | ? 'Preparing Print' 212 | : readablePrintStatus === 'paused' 213 | ? 'Print Paused' 214 | : readablePrintStatus === 'stopped' 215 | ? 'Print Stopped' 216 | : readablePrintStatus === 'unknown' 217 | ? 'Unknown Status' 218 | : 'No active print'} 219 | 220 | {totalLayer > 0 && ( 221 | 222 | 223 | Layer {currentLayer}/{totalLayer} 224 | 225 | 226 | ~ {formatTicksToReadableTime(totalTicks - currentTicks)}{' '} 227 | remaining 228 | 229 | 230 | )} 231 | 242 | 243 | {readablePrintStatus === 'completed' 244 | ? '100% Complete' 245 | : progress > 0 246 | ? `${progress}% Complete` 247 | : '0% Complete'} 248 | 249 | 250 | )} 251 | {showPrintControls && onPrintControl && ( 252 | 253 | 254 | 255 | {canStop && onStopPrint && ( 256 | 261 | 262 | 263 | )} 264 | {(canPause || canResume) && ( 265 | 270 | 275 | 276 | )} 277 | 278 | 279 | )} 280 | 281 | )} 282 | 283 | )} 284 | 285 | ); 286 | }; 287 | -------------------------------------------------------------------------------- /src/components/icons/PrinterIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Svg, { Rect, Circle } from 'react-native-svg'; 3 | 4 | export type PrinterIconProps = { 5 | variant?: 'filled' | 'outline'; 6 | size?: number; 7 | color?: string; 8 | }; 9 | 10 | const PrinterIcon: React.FC = ({ 11 | variant = 'outline', 12 | size = 24, 13 | color = 'black', 14 | }) => { 15 | if (variant === 'filled') { 16 | return ( 17 | 18 | 28 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | return ( 44 | 45 | 55 | 64 | 65 | ); 66 | }; 67 | 68 | export default PrinterIcon; 69 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | // Export all components for easier imports 2 | export { Header } from './Header'; 3 | -------------------------------------------------------------------------------- /src/contexts/PrinterConnectionsContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useState, 4 | useEffect, 5 | useContext, 6 | ReactNode, 7 | useRef, 8 | } from 'react'; 9 | import AsyncStorage from '@react-native-async-storage/async-storage'; 10 | import { AppState, AppStateStatus, Alert } from 'react-native'; 11 | import { Printer, ConnectionStatus, PrinterStatus } from '../types'; 12 | 13 | interface PrinterConnectionsContextType { 14 | printers: Printer[]; 15 | addPrinter: (printerName: string, ipAddress: string) => void; 16 | removePrinter: (id: string) => void; 17 | reconnectAll: () => void; 18 | sendCommand: (printerId: string, command: any) => void; 19 | } 20 | 21 | const PrinterConnectionsContext = createContext< 22 | PrinterConnectionsContextType | undefined 23 | >(undefined); 24 | 25 | // A simple ID generator to avoid external dependencies. 26 | const generateId = () => { 27 | return Date.now().toString(36) + Math.random().toString(36).substr(2); 28 | }; 29 | 30 | const CONNECTION_TIMEOUT = 3000; // 3 seconds 31 | const STATUS_UPDATE_INTERVAL = 31000; // 30 seconds 32 | 33 | // Helper function to get readable command names 34 | const getCommandName = (cmdCode: number): string => { 35 | switch (cmdCode) { 36 | case 129: 37 | return 'Pause Print'; 38 | case 130: 39 | return 'Stop Print'; 40 | case 131: 41 | return 'Resume Print'; 42 | case 386: 43 | return 'Enable Video'; 44 | case 403: 45 | return 'Change Setting'; 46 | default: 47 | return `Command ${cmdCode}`; 48 | } 49 | }; 50 | 51 | export const PrinterConnectionsProvider = ({ 52 | children, 53 | }: { 54 | children: ReactNode; 55 | }) => { 56 | const [printers, setPrinters] = useState([]); 57 | const webSocketsRef = useRef<{ [key: string]: WebSocket }>({}); 58 | const statusTimersRef = useRef<{ [key: string]: NodeJS.Timeout }>({}); 59 | const printersRef = useRef([]); 60 | const appStateRef = useRef(AppState.currentState); 61 | const pendingCommandsRef = useRef<{ 62 | [key: string]: { command: string; timestamp: number }; 63 | }>({}); 64 | 65 | useEffect(() => { 66 | const loadPrinters = async () => { 67 | try { 68 | const savedPrintersJSON = await AsyncStorage.getItem('printers'); 69 | console.log('savedPrintersJSON', savedPrintersJSON); 70 | if (savedPrintersJSON) { 71 | const savedPrinters = JSON.parse(savedPrintersJSON); 72 | const loadedPrinters = savedPrinters.map( 73 | (p: Omit) => ({ 74 | ...p, 75 | connectionStatus: 'disconnected' as ConnectionStatus, 76 | }) 77 | ); 78 | setPrinters(loadedPrinters); 79 | printersRef.current = loadedPrinters; 80 | console.log('loadedPrinters', loadedPrinters); 81 | loadedPrinters.forEach((p: Printer) => connectToPrinter(p)); 82 | } 83 | } catch (e) { 84 | console.error('Failed to load printers from storage', e); 85 | } 86 | }; 87 | 88 | loadPrinters(); 89 | 90 | return () => { 91 | Object.values(webSocketsRef.current).forEach(ws => ws.close()); 92 | Object.values(statusTimersRef.current).forEach(timer => 93 | clearInterval(timer) 94 | ); 95 | }; 96 | }, []); 97 | 98 | // Handle app state changes (background/foreground) 99 | useEffect(() => { 100 | const handleAppStateChange = (nextAppState: AppStateStatus) => { 101 | console.log( 102 | 'App state changed from', 103 | appStateRef.current, 104 | 'to', 105 | nextAppState 106 | ); 107 | 108 | if ( 109 | appStateRef.current.match(/inactive|background/) && 110 | nextAppState === 'active' 111 | ) { 112 | console.log( 113 | 'App came to foreground, reconnecting disconnected printers...' 114 | ); 115 | // Small delay to ensure app is fully active 116 | setTimeout(() => { 117 | reconnectDisconnectedPrinters(); 118 | }, 1000); 119 | } 120 | 121 | appStateRef.current = nextAppState; 122 | }; 123 | 124 | const subscription = AppState.addEventListener( 125 | 'change', 126 | handleAppStateChange 127 | ); 128 | 129 | return () => { 130 | subscription?.remove(); 131 | }; 132 | }, []); 133 | 134 | useEffect(() => { 135 | const savePrinters = async () => { 136 | try { 137 | const printersToSave = printers.map( 138 | ({ id, printerName, ipAddress }) => ({ 139 | id, 140 | printerName, 141 | ipAddress, 142 | }) 143 | ); 144 | await AsyncStorage.setItem('printers', JSON.stringify(printersToSave)); 145 | } catch (e) { 146 | console.error('Failed to save printers to storage', e); 147 | } 148 | }; 149 | 150 | // Only save if printers state has been initialized from storage 151 | if (printers.length > 0) { 152 | savePrinters(); 153 | } 154 | }, [printers]); 155 | 156 | const updatePrinterStatus = ( 157 | id: string, 158 | connectionStatus: ConnectionStatus 159 | ) => { 160 | setPrinters(prevPrinters => { 161 | const updatedPrinters = prevPrinters.map(p => 162 | p.id === id ? { ...p, connectionStatus } : p 163 | ); 164 | printersRef.current = updatedPrinters; 165 | return updatedPrinters; 166 | }); 167 | }; 168 | 169 | const updatePrinterData = (id: string, status: PrinterStatus) => { 170 | setPrinters(prevPrinters => { 171 | const updatedPrinters = prevPrinters.map(p => 172 | p.id === id ? { ...p, status, lastUpdate: Date.now() } : p 173 | ); 174 | printersRef.current = updatedPrinters; 175 | return updatedPrinters; 176 | }); 177 | }; 178 | 179 | const updatePrinterVideoUrl = (id: string, videoUrl: string) => { 180 | setPrinters(prevPrinters => { 181 | const updatedPrinters = prevPrinters.map(p => 182 | p.id === id ? { ...p, videoUrl } : p 183 | ); 184 | printersRef.current = updatedPrinters; 185 | return updatedPrinters; 186 | }); 187 | }; 188 | 189 | const sendStatusRequest = (printer: Printer, ws?: WebSocket) => { 190 | console.log('Sending status request to printer', printer.printerName); 191 | const websocket = ws || webSocketsRef.current[printer.id]; 192 | console.log('websocket', websocket); 193 | 194 | if (!websocket) { 195 | console.log( 196 | `No WebSocket found for ${printer.printerName}, stopping timer` 197 | ); 198 | stopStatusTimer(printer.id); 199 | return; 200 | } 201 | 202 | if (websocket.readyState === WebSocket.OPEN) { 203 | const statusRequest = { 204 | Id: `${printer.printerName}-id-${Date.now()}`, 205 | Data: { 206 | Cmd: 0, 207 | Data: {}, 208 | RequestID: `STATUS_REQUEST`, 209 | TimeStamp: Date.now(), 210 | MainboardID: '', 211 | From: 1, 212 | }, 213 | }; 214 | console.log(`Sending status request to ${printer.printerName}`); 215 | websocket.send(JSON.stringify(statusRequest)); 216 | } else { 217 | console.log( 218 | `WebSocket not open for ${printer.printerName}, state: ${websocket.readyState}` 219 | ); 220 | if ( 221 | websocket.readyState === WebSocket.CLOSED || 222 | websocket.readyState === WebSocket.CLOSING 223 | ) { 224 | console.log( 225 | `WebSocket closed for ${printer.printerName}, stopping timer` 226 | ); 227 | stopStatusTimer(printer.id); 228 | updatePrinterStatus(printer.id, 'disconnected'); 229 | } 230 | } 231 | }; 232 | 233 | const startStatusTimer = (printer: Printer) => { 234 | console.log( 235 | `Starting status timer for ${printer.printerName} (${printer.id})` 236 | ); 237 | 238 | // Clear any existing timer for this printer 239 | if (statusTimersRef.current[printer.id]) { 240 | console.log(`Clearing existing timer for ${printer.id}`); 241 | clearInterval(statusTimersRef.current[printer.id]); 242 | } 243 | 244 | // Start a new timer 245 | const timer = setInterval(() => { 246 | console.log(`Timer tick for ${printer.printerName}`); 247 | // Check if printer is still connected before sending - use ref to get current state 248 | const currentPrinter = printersRef.current.find(p => p.id === printer.id); 249 | console.log('Current printers from ref:', printersRef.current); 250 | if (currentPrinter && currentPrinter.connectionStatus === 'connected') { 251 | sendStatusRequest(printer); 252 | } else { 253 | console.log( 254 | `Printer ${printer.printerName} is not connected, stopping timer` 255 | ); 256 | stopStatusTimer(printer.id); 257 | } 258 | }, STATUS_UPDATE_INTERVAL); 259 | 260 | console.log(`Created timer ${timer} for ${printer.id}`); 261 | statusTimersRef.current[printer.id] = timer; 262 | console.log(`Updated timers:`, Object.keys(statusTimersRef.current)); 263 | }; 264 | 265 | const stopStatusTimer = (printerId: string) => { 266 | console.log(`Attempting to stop status timer for printer ${printerId}`); 267 | console.log(`Current status timers:`, Object.keys(statusTimersRef.current)); 268 | 269 | if (statusTimersRef.current[printerId]) { 270 | console.log(`Found timer for ${printerId}, clearing it`); 271 | clearInterval(statusTimersRef.current[printerId]); 272 | delete statusTimersRef.current[printerId]; 273 | console.log( 274 | `Updated timers after removal:`, 275 | Object.keys(statusTimersRef.current) 276 | ); 277 | } else { 278 | console.log(`No timer found for ${printerId}`); 279 | } 280 | }; 281 | 282 | const connectToPrinter = (printer: Printer) => { 283 | // If a websocket for this printer already exists, close it before creating a new one. 284 | if (webSocketsRef.current[printer.id]) { 285 | webSocketsRef.current[printer.id].close(); 286 | } 287 | 288 | updatePrinterStatus(printer.id, 'connecting'); 289 | const ws = new WebSocket(`ws://${printer.ipAddress}/websocket`); 290 | 291 | const timeout = setTimeout(() => { 292 | console.log(`Connection to ${printer.printerName} timed out.`); 293 | updatePrinterStatus(printer.id, 'timeout'); 294 | ws.close(); 295 | }, CONNECTION_TIMEOUT); 296 | 297 | ws.onopen = () => { 298 | clearTimeout(timeout); 299 | console.log(`Connected to printer: ${printer.printerName}`); 300 | updatePrinterStatus(printer.id, 'connected'); 301 | 302 | // Set initial lastUpdate when connected 303 | setPrinters(prevPrinters => { 304 | const updatedPrinters = prevPrinters.map(p => 305 | p.id === printer.id ? { ...p, lastUpdate: Date.now() } : p 306 | ); 307 | printersRef.current = updatedPrinters; 308 | return updatedPrinters; 309 | }); 310 | 311 | // Send enable command 312 | const enableCommand = { 313 | Id: '', 314 | Data: { 315 | Cmd: 386, 316 | Data: { 317 | Enable: 1, 318 | }, 319 | RequestID: 'ENABLE_VIDEO', 320 | MainboardID: '', 321 | TimeStamp: Date.now(), 322 | From: 1, 323 | }, 324 | }; 325 | console.log( 326 | `Sending enable command to ${printer.printerName}:`, 327 | enableCommand 328 | ); 329 | ws.send(JSON.stringify(enableCommand)); 330 | 331 | // Send initial status request using the ws instance directly 332 | sendStatusRequest(printer, ws); 333 | 334 | // Start periodic status updates 335 | startStatusTimer(printer); 336 | }; 337 | 338 | ws.onmessage = event => { 339 | try { 340 | const data = JSON.parse(event.data); 341 | 342 | console.log('data', data); 343 | 344 | if (data.Status) { 345 | console.log( 346 | `Status update from ${printer.printerName}:`, 347 | data.Status 348 | ); 349 | updatePrinterData(printer.id, data.Status); 350 | } 351 | 352 | // Check for ACK response 353 | if ( 354 | data.Data && 355 | data.Data.Data && 356 | data.Data.Data.Ack === 0 && 357 | data.Data.RequestID !== 'STATUS_REQUEST' 358 | ) { 359 | if (data.Data.Data.VideoUrl) { 360 | console.log('VideoUrl', data.Data.Data.VideoUrl); 361 | updatePrinterVideoUrl(printer.id, data.Data.Data.VideoUrl); 362 | } 363 | console.log(`ACK received from ${printer.printerName}`); 364 | // Send status request using the ws instance directly 365 | sendStatusRequest(printer, ws); 366 | } 367 | if (data.Data && data.Data.Data && data.Data.Data.Ack > 0) { 368 | console.log(`ACK non zero received from ${printer.printerName}`); 369 | const requestId = data.Data.RequestID; 370 | const commandName = 371 | pendingCommandsRef.current[requestId]?.command || 'Unknown command'; 372 | 373 | // Remove from pending commands 374 | delete pendingCommandsRef.current[requestId]; 375 | 376 | // Show alert to user 377 | Alert.alert( 378 | 'Command Rejected', 379 | `The command "${commandName}" was not accepted. The manufacturer has blocked this command while printing.`, 380 | [{ text: 'OK' }] 381 | ); 382 | } 383 | } catch (error) { 384 | console.log( 385 | `Error parsing message from ${printer.printerName}:`, 386 | error 387 | ); 388 | console.log(`Raw message:`, event.data); 389 | } 390 | }; 391 | 392 | ws.onerror = (error: unknown) => { 393 | clearTimeout(timeout); 394 | console.error(`error for ${printer.printerName}:`, error); 395 | if ( 396 | error && 397 | (error as Error).message === 'Software caused connection abort' 398 | ) { 399 | updatePrinterStatus(printer.id, 'disconnected'); 400 | } else { 401 | updatePrinterStatus(printer.id, 'error'); 402 | } 403 | stopStatusTimer(printer.id); 404 | }; 405 | 406 | ws.onclose = event => { 407 | clearTimeout(timeout); 408 | console.log( 409 | `Disconnected from printer: ${printer.printerName}, code: ${event.code}, reason: ${event.reason}` 410 | ); 411 | stopStatusTimer(printer.id); 412 | setPrinters(prev => { 413 | const printerToUpdate = prev.find(p => p.id === printer.id); 414 | if ( 415 | printerToUpdate && 416 | (printerToUpdate.connectionStatus === 'connected' || 417 | printerToUpdate.connectionStatus === 'connecting') 418 | ) { 419 | const updatedPrinters = prev.map(p => 420 | p.id === printer.id 421 | ? { ...p, connectionStatus: 'disconnected' as ConnectionStatus } 422 | : p 423 | ); 424 | printersRef.current = updatedPrinters; 425 | return updatedPrinters; 426 | } 427 | return prev; 428 | }); 429 | }; 430 | 431 | webSocketsRef.current[printer.id] = ws; 432 | }; 433 | 434 | const addPrinter = (printerName: string, ipAddress: string) => { 435 | const newPrinter: Printer = { 436 | id: generateId(), 437 | printerName, 438 | ipAddress, 439 | connectionStatus: 'connecting', 440 | }; 441 | 442 | setPrinters(prevPrinters => { 443 | const existingPrinter = prevPrinters.find(p => p.ipAddress === ipAddress); 444 | if (existingPrinter) { 445 | // Maybe alert the user that the printer already exists 446 | console.log('printer already exists', existingPrinter); 447 | return prevPrinters; 448 | } 449 | const updatedPrinters = [...prevPrinters, newPrinter]; 450 | printersRef.current = updatedPrinters; 451 | return updatedPrinters; 452 | }); 453 | 454 | connectToPrinter(newPrinter); 455 | }; 456 | 457 | const removePrinter = (id: string) => { 458 | const ws = webSocketsRef.current[id]; 459 | if (ws) { 460 | ws.close(); 461 | delete webSocketsRef.current[id]; 462 | } 463 | stopStatusTimer(id); 464 | console.log('remove printer', id); 465 | setPrinters(prevPrinters => { 466 | const updatedPrinters = prevPrinters.filter(p => p.id !== id); 467 | printersRef.current = updatedPrinters; 468 | return updatedPrinters; 469 | }); 470 | // Also remove from AsyncStorage 471 | AsyncStorage.getItem('printers').then(printersJSON => { 472 | if (printersJSON) { 473 | const printers = JSON.parse(printersJSON); 474 | const filteredPrinters = printers.filter((p: Printer) => p.id !== id); 475 | AsyncStorage.setItem('printers', JSON.stringify(filteredPrinters)); 476 | } 477 | }); 478 | }; 479 | 480 | const sendCommand = (printerId: string, command: any) => { 481 | const ws = webSocketsRef.current[printerId]; 482 | if (ws && ws.readyState === WebSocket.OPEN) { 483 | console.log(`Sending command to printer ${printerId}:`, command); 484 | 485 | // Track the command for ACK response 486 | const requestId = command.Data?.RequestID; 487 | if (requestId) { 488 | const commandName = getCommandName(command.Data?.Cmd); 489 | pendingCommandsRef.current[requestId] = { 490 | command: commandName, 491 | timestamp: Date.now(), 492 | }; 493 | 494 | // Clean up old pending commands (older than 30 seconds) 495 | const now = Date.now(); 496 | Object.keys(pendingCommandsRef.current).forEach(key => { 497 | if (now - pendingCommandsRef.current[key].timestamp > 30000) { 498 | delete pendingCommandsRef.current[key]; 499 | } 500 | }); 501 | } 502 | 503 | ws.send(JSON.stringify(command)); 504 | } else { 505 | console.error( 506 | `Cannot send command: WebSocket not connected for printer ${printerId}` 507 | ); 508 | } 509 | }; 510 | 511 | const reconnectAll = () => { 512 | console.log('Reconnecting to all printers...'); 513 | printers.forEach(connectToPrinter); 514 | }; 515 | 516 | const reconnectDisconnectedPrinters = () => { 517 | console.log('Reconnecting only disconnected printers...'); 518 | console.log( 519 | 'Current printer statuses:', 520 | printersRef.current.map(p => `${p.printerName}: ${p.connectionStatus}`) 521 | ); 522 | 523 | const disconnectedPrinters = printersRef.current.filter( 524 | p => 525 | p.connectionStatus === 'disconnected' || 526 | p.connectionStatus === 'error' || 527 | p.connectionStatus === 'timeout' 528 | ); 529 | 530 | if (disconnectedPrinters.length > 0) { 531 | console.log( 532 | `Found ${disconnectedPrinters.length} disconnected printers, reconnecting...` 533 | ); 534 | disconnectedPrinters.forEach(printer => { 535 | console.log( 536 | `Attempting to reconnect to ${printer.printerName} (${printer.connectionStatus})` 537 | ); 538 | connectToPrinter(printer); 539 | }); 540 | } else { 541 | console.log('All printers are connected, no need to reconnect'); 542 | } 543 | }; 544 | 545 | return ( 546 | 549 | {children} 550 | 551 | ); 552 | }; 553 | 554 | export const usePrinterConnections = () => { 555 | const context = useContext(PrinterConnectionsContext); 556 | if (context === undefined) { 557 | throw new Error( 558 | 'usePrinterConnections must be used within a PrinterConnectionsProvider' 559 | ); 560 | } 561 | return context; 562 | }; 563 | -------------------------------------------------------------------------------- /src/contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useEffect, useContext } from 'react'; 2 | import { useColorScheme } from 'nativewind'; 3 | import AsyncStorage from '@react-native-async-storage/async-storage'; 4 | import { Appearance } from 'react-native'; 5 | 6 | type Theme = 'light' | 'dark' | 'system'; 7 | 8 | interface ThemeContextType { 9 | theme: Theme; 10 | setTheme: (theme: Theme) => void; 11 | colorScheme: 'light' | 'dark'; 12 | } 13 | 14 | const ThemeContext = createContext(undefined); 15 | 16 | export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { 17 | const { colorScheme, setColorScheme } = useColorScheme(); 18 | const [theme, _setTheme] = useState('system'); 19 | 20 | useEffect(() => { 21 | const getTheme = async () => { 22 | try { 23 | const savedTheme = (await AsyncStorage.getItem( 24 | 'theme' 25 | )) as Theme | null; 26 | if (savedTheme) { 27 | _setTheme(savedTheme); 28 | } 29 | } catch (e) { 30 | console.error('Failed to load theme from storage', e); 31 | } 32 | }; 33 | 34 | getTheme(); 35 | }, []); 36 | 37 | useEffect(() => { 38 | if (theme === 'system') { 39 | setColorScheme(Appearance.getColorScheme() ?? 'light'); 40 | } else { 41 | setColorScheme(theme); 42 | } 43 | }, [theme, setColorScheme]); 44 | 45 | const setTheme = async (newTheme: Theme) => { 46 | try { 47 | await AsyncStorage.setItem('theme', newTheme); 48 | _setTheme(newTheme); 49 | } catch (e) { 50 | console.error('Failed to save theme to storage', e); 51 | } 52 | }; 53 | 54 | return ( 55 | 58 | {children} 59 | 60 | ); 61 | }; 62 | 63 | export const useTheme = () => { 64 | const context = useContext(ThemeContext); 65 | if (context === undefined) { 66 | throw new Error('useTheme must be used within a ThemeProvider'); 67 | } 68 | return context; 69 | }; 70 | -------------------------------------------------------------------------------- /src/navigation/AppNavigator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 3 | import { createStackNavigator } from '@react-navigation/stack'; 4 | import { Ionicons } from '@expo/vector-icons'; 5 | import { HomeScreen, SettingsScreen } from '../screens'; 6 | import { PrinterDetailsScreen } from '../screens/PrinterDetailsScreen'; 7 | import { AddEditPrinterScreen } from '../screens/AddEditPrinterScreen'; 8 | import WhereIsIpScreen from '../screens/WhereIsIpScreen'; 9 | import PrinterIcon from '../components/icons/PrinterIcon'; 10 | import { useTheme } from '../contexts/ThemeContext'; 11 | 12 | const Tab = createBottomTabNavigator(); 13 | const Stack = createStackNavigator(); 14 | 15 | const PrintersStackNavigator = () => ( 16 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | export const AppNavigator = () => { 30 | const { colorScheme } = useTheme(); 31 | 32 | return ( 33 | ({ 35 | tabBarIcon: ({ focused, color, size }) => { 36 | if (route.name === 'Printers') { 37 | return ( 38 | 43 | ); 44 | } else if (route.name === 'Settings') { 45 | return ( 46 | 51 | ); 52 | } else { 53 | return ; 54 | } 55 | }, 56 | tabBarActiveTintColor: '#3B82F6', 57 | tabBarInactiveTintColor: colorScheme === 'dark' ? '#9CA3AF' : '#6B7280', 58 | headerShown: false, 59 | tabBarStyle: { 60 | backgroundColor: colorScheme === 'dark' ? '#111827' : '#f1f5f9', 61 | paddingBottom: 5, 62 | paddingTop: 5, 63 | height: 80, 64 | }, 65 | })} 66 | > 67 | 74 | 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/screens/AddEditPrinterScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | View, 4 | Text, 5 | ScrollView, 6 | TextInput, 7 | TouchableOpacity, 8 | } from 'react-native'; 9 | import { SafeAreaView } from 'react-native-safe-area-context'; 10 | import { Header } from '../components/Header'; 11 | import * as yup from 'yup'; 12 | import { useNavigation } from '@react-navigation/native'; 13 | import { Ionicons } from '@expo/vector-icons'; 14 | import { usePrinterConnections } from '../contexts/PrinterConnectionsContext'; 15 | import { checkLocalNetworkPermission } from '../utils/LocalNetworkUtils'; 16 | 17 | interface FormData { 18 | printerName: string; 19 | ipAddress: string; 20 | } 21 | 22 | interface FormErrors { 23 | printerName?: string; 24 | ipAddress?: string; 25 | } 26 | 27 | const validationSchema = yup.object().shape({ 28 | printerName: yup 29 | .string() 30 | .required('Printer name is required') 31 | .min(2, 'Printer name must be at least 2 characters') 32 | .max(50, 'Printer name must be less than 50 characters'), 33 | ipAddress: yup.string().required('IP address is required'), 34 | }); 35 | 36 | export const AddEditPrinterScreen = () => { 37 | const navigation = useNavigation(); 38 | const { addPrinter } = usePrinterConnections(); 39 | const [formData, setFormData] = useState({ 40 | printerName: '', 41 | ipAddress: '', 42 | }); 43 | const [errors, setErrors] = useState({}); 44 | const [isSubmitting, setIsSubmitting] = useState(false); 45 | 46 | const handleInputChange = (field: keyof FormData, value: string) => { 47 | setFormData(prev => ({ ...prev, [field]: value })); 48 | // Clear error when user starts typing 49 | if (errors[field]) { 50 | setErrors(prev => ({ ...prev, [field]: undefined })); 51 | } 52 | }; 53 | 54 | const handleSubmit = async () => { 55 | console.log('handleSubmit', formData); 56 | setIsSubmitting(true); 57 | try { 58 | await validationSchema.validate(formData, { abortEarly: false }); 59 | 60 | // Check local network access on iOS before adding printer 61 | const hasNetworkAccess = await checkLocalNetworkPermission(); 62 | if (!hasNetworkAccess) { 63 | setIsSubmitting(false); 64 | return; 65 | } 66 | 67 | addPrinter(formData.printerName, formData.ipAddress); 68 | navigation.goBack(); 69 | } catch (validationErrors: any) { 70 | const newErrors: FormErrors = {}; 71 | validationErrors.inner.forEach((error: any) => { 72 | newErrors[error.path as keyof FormErrors] = error.message; 73 | }); 74 | setErrors(newErrors); 75 | } finally { 76 | setIsSubmitting(false); 77 | } 78 | }; 79 | 80 | return ( 81 | 85 |
86 | 87 | 88 | {/* Back Button */} 89 | navigation.goBack()} 92 | > 93 | 94 | 95 | BACK 96 | 97 | 98 | 99 | {/* Form Container */} 100 | 101 | 102 | Printer Information 103 | 104 | 105 | {/* Printer Name Input */} 106 | 107 | 108 | Printer Name 109 | 110 | handleInputChange('printerName', value)} 120 | autoCapitalize="words" 121 | /> 122 | {errors.printerName && ( 123 | 124 | {errors.printerName} 125 | 126 | )} 127 | 128 | 129 | {/* IP Address Input */} 130 | 131 | 132 | 133 | IP Address or Hostname 134 | 135 | navigation.navigate('WhereIsIp' as never)} 137 | > 138 | Where is this? 139 | 140 | 141 | handleInputChange('ipAddress', value)} 151 | autoCapitalize="none" 152 | /> 153 | {errors.ipAddress && ( 154 | 155 | {errors.ipAddress} 156 | 157 | )} 158 | 159 | 160 | {/* Submit Button */} 161 | 168 | 169 | {isSubmitting ? 'Saving...' : 'SAVE'} 170 | 171 | 172 | 173 | 174 | 175 | 176 | ); 177 | }; 178 | -------------------------------------------------------------------------------- /src/screens/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from 'react'; 2 | import { 3 | View, 4 | ScrollView, 5 | RefreshControl, 6 | Text, 7 | TouchableOpacity, 8 | Image, 9 | Alert, 10 | Linking, 11 | } from 'react-native'; 12 | import { SafeAreaView } from 'react-native-safe-area-context'; 13 | import { Header } from '../components/Header'; 14 | import { PrinterCard } from '../components/PrinterCard'; 15 | import { usePrinterConnections } from '../contexts/PrinterConnectionsContext'; 16 | import { Ionicons } from '@expo/vector-icons'; 17 | import { checkLocalNetworkAccess } from '@generac/react-native-local-network-permission'; 18 | import AsyncStorage from '@react-native-async-storage/async-storage'; 19 | 20 | export const HomeScreen = ({ navigation }: any) => { 21 | const [refreshing, setRefreshing] = useState(false); 22 | const [hasNetworkAccess, setHasNetworkAccess] = useState(true); 23 | const [showSurvey, setShowSurvey] = useState(true); 24 | const { printers, reconnectAll, removePrinter } = usePrinterConnections(); 25 | 26 | // Check local network access when component mounts 27 | const checkNetworkAccess = async () => { 28 | const hasAccess = await checkLocalNetworkAccess(); 29 | setHasNetworkAccess(hasAccess); 30 | }; 31 | 32 | const handleSurveyPress = async () => { 33 | const url = 34 | 'https://docs.google.com/forms/d/e/1FAIpQLSfGm5E6OpePT1nECJaQ_uffvfMTWsw4JDxzsDC6T8nNyjVoww/viewform?usp=header'; 35 | try { 36 | const supported = await Linking.canOpenURL(url); 37 | if (supported) { 38 | await Linking.openURL(url); 39 | } else { 40 | Alert.alert('Error', 'Cannot open survey link'); 41 | } 42 | } catch (error) { 43 | Alert.alert('Error', 'Failed to open survey link'); 44 | } 45 | }; 46 | 47 | const handleDismissSurvey = async () => { 48 | try { 49 | await AsyncStorage.setItem('surveyDismissed', 'true'); 50 | setShowSurvey(false); 51 | } catch (error) { 52 | console.error('Failed to save survey dismissal state:', error); 53 | // Still hide the survey even if saving fails 54 | setShowSurvey(false); 55 | } 56 | }; 57 | useEffect(() => { 58 | const loadSurveyState = async () => { 59 | try { 60 | const surveyDismissed = await AsyncStorage.getItem('surveyDismissed'); 61 | if (surveyDismissed === 'true') { 62 | setShowSurvey(false); 63 | } 64 | } catch (error) { 65 | console.error('Failed to load survey state:', error); 66 | } 67 | }; 68 | 69 | checkNetworkAccess(); 70 | loadSurveyState(); 71 | }, []); 72 | 73 | const onRefresh = useCallback(() => { 74 | setRefreshing(true); 75 | reconnectAll(); 76 | checkNetworkAccess(); 77 | // Keep the timeout to give visual feedback on the refresh control 78 | setTimeout(() => setRefreshing(false), 1000); 79 | }, [reconnectAll]); 80 | 81 | const handlePrinterPress = (printerId: string) => { 82 | const printer = printers.find(p => p.id === printerId); 83 | if (printer?.connectionStatus === 'connected') { 84 | navigation.navigate('PrinterDetails', { printerId }); 85 | } else { 86 | Alert.alert( 87 | 'Printer Offline', 88 | `${printer?.printerName} is not connected. What would you like to do?`, 89 | [ 90 | { 91 | text: 'Cancel', 92 | style: 'cancel', 93 | }, 94 | { 95 | text: 'Delete Printer', 96 | style: 'destructive', 97 | onPress: () => { 98 | Alert.alert( 99 | 'Delete Printer', 100 | `Are you sure you want to delete ${printer?.printerName}? This action cannot be undone.`, 101 | [ 102 | { 103 | text: 'Cancel', 104 | style: 'cancel', 105 | }, 106 | { 107 | text: 'Delete', 108 | style: 'destructive', 109 | onPress: () => { 110 | if (printer) { 111 | removePrinter(printer.id); 112 | } 113 | }, 114 | }, 115 | ] 116 | ); 117 | }, 118 | }, 119 | ] 120 | ); 121 | } 122 | }; 123 | 124 | return ( 125 | 129 |
navigation.navigate('AddEditPrinter'), 135 | }} 136 | /> 137 | 138 | {/* Local Network Access Disclaimer */} 139 | 140 | 144 | } 145 | showsVerticalScrollIndicator={false} 146 | contentContainerStyle={{ flexGrow: 1 }} 147 | > 148 | {printers.length > 0 && !hasNetworkAccess && ( 149 | 150 | 151 | 157 | 158 | 159 | We need access to local networking to connect to your 160 | printers. Please enable local network access in{' '} 161 | Linking.openSettings()} 164 | > 165 | Settings 166 | 167 | . 168 | 169 | 170 | 171 | 172 | )} 173 | 174 | {printers.length === 0 ? ( 175 | // Getting Started Card 176 | 177 | 178 | 179 | 180 | 185 | 186 | 187 | Getting Started 188 | 189 | 190 | Get started by adding your first printer to monitor your 191 | prints. 192 | 193 | 194 | 195 | navigation.navigate('AddEditPrinter')} 198 | > 199 | 200 | ADD PRINTER 201 | 202 | 203 | 204 | 205 | ) : ( 206 | // Printer Cards 207 | <> 208 | {printers.map(printer => ( 209 | 210 | handlePrinterPress(printer.id)} 215 | /> 216 | 217 | ))} 218 | 219 | )} 220 | 221 | 222 | ); 223 | }; 224 | -------------------------------------------------------------------------------- /src/screens/PrinterDetailsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | Text, 4 | ScrollView, 5 | TouchableOpacity, 6 | Switch, 7 | RefreshControl, 8 | Alert, 9 | AppState, 10 | AppStateStatus, 11 | Dimensions, 12 | Modal, 13 | } from 'react-native'; 14 | import { SafeAreaView } from 'react-native-safe-area-context'; 15 | import { Header } from '../components/Header'; 16 | import { Ionicons } from '@expo/vector-icons'; 17 | import { PrinterCard } from '../components/PrinterCard'; 18 | import { useState, useCallback, useRef, useEffect } from 'react'; 19 | import { WebView } from 'react-native-webview'; 20 | import { useRoute } from '@react-navigation/native'; 21 | import { usePrinterConnections } from '../contexts/PrinterConnectionsContext'; 22 | import { formatTextMaxEllipsis } from '~/utils/FormatUtils'; 23 | 24 | export const PrinterDetailsScreen = ({ navigation }: any) => { 25 | const route = useRoute(); 26 | const { printerId } = route.params as { printerId: string }; 27 | const { printers, sendCommand, reconnectAll, removePrinter } = 28 | usePrinterConnections(); 29 | const printer = printers.find(p => p.id === printerId); 30 | 31 | const [refreshing, setRefreshing] = useState(false); 32 | const [webViewKey, setWebViewKey] = useState(0); 33 | const [chamberFanState, setChamberFanState] = useState(false); 34 | const [modelFanState, setModelFanState] = useState(false); 35 | const [sideFanState, setSideFanState] = useState(false); 36 | const [isWebViewInteracting, setIsWebViewInteracting] = useState(false); 37 | const [isFullScreen, setIsFullScreen] = useState(false); 38 | const webViewInteractionTimeout = useRef(null); 39 | const appState = useRef(AppState.currentState); 40 | 41 | // Handle app state changes to remount WebView when app comes back from background 42 | useEffect(() => { 43 | const handleAppStateChange = (nextAppState: AppStateStatus) => { 44 | if ( 45 | appState.current.match(/inactive|background/) && 46 | nextAppState === 'active' 47 | ) { 48 | // App has come to the foreground, remount the WebView 49 | console.log('App has come to the foreground, remounting WebView'); 50 | setWebViewKey(prevKey => prevKey + 1); 51 | 52 | // If in full-screen, exit it 53 | if (isFullScreen) { 54 | setIsFullScreen(false); 55 | } 56 | } 57 | appState.current = nextAppState; 58 | }; 59 | 60 | const subscription = AppState.addEventListener( 61 | 'change', 62 | handleAppStateChange 63 | ); 64 | 65 | return () => { 66 | subscription?.remove(); 67 | }; 68 | }, [isFullScreen]); 69 | 70 | // Sync fan states with printer status 71 | useEffect(() => { 72 | if (printer?.status?.CurrentFanSpeed) { 73 | setModelFanState((printer.status.CurrentFanSpeed.ModelFan || 0) > 0); 74 | setChamberFanState((printer.status.CurrentFanSpeed.BoxFan || 0) > 0); 75 | setSideFanState((printer.status.CurrentFanSpeed.AuxiliaryFan || 0) > 0); 76 | } 77 | }, [printer?.status?.CurrentFanSpeed]); 78 | 79 | // Cleanup timeout on unmount 80 | useEffect(() => { 81 | return () => { 82 | if (webViewInteractionTimeout.current) { 83 | clearTimeout(webViewInteractionTimeout.current); 84 | } 85 | }; 86 | }, []); 87 | 88 | const handleBackPress = () => { 89 | navigation.goBack(); 90 | }; 91 | 92 | const handleWebViewTouchStart = () => { 93 | setIsWebViewInteracting(true); 94 | // Clear any existing timeout 95 | if (webViewInteractionTimeout.current) { 96 | clearTimeout(webViewInteractionTimeout.current); 97 | webViewInteractionTimeout.current = null; 98 | } 99 | }; 100 | 101 | const handleWebViewTouchEnd = () => { 102 | // Set a timeout to reset the interaction state 103 | // This gives a small buffer in case of gesture conflicts 104 | webViewInteractionTimeout.current = setTimeout(() => { 105 | setIsWebViewInteracting(false); 106 | webViewInteractionTimeout.current = null; 107 | }, 100); 108 | }; 109 | 110 | const handleToggleFullScreen = () => { 111 | setIsFullScreen(!isFullScreen); 112 | }; 113 | 114 | const handlePrintControl = () => { 115 | if (!printer) return; 116 | 117 | const printStatusFromPrinter = printer.status?.PrintInfo?.Status || 0; 118 | const canPause = printStatusFromPrinter === 13; // printing 119 | const canResume = printStatusFromPrinter === 6; // paused 120 | 121 | if (canPause) { 122 | // Send pause command 123 | const pauseCommand = { 124 | Id: '', 125 | Data: { 126 | Cmd: 129, 127 | Data: {}, 128 | RequestID: `pause-${Date.now()}`, 129 | MainboardID: '', 130 | TimeStamp: Date.now(), 131 | From: 1, 132 | }, 133 | }; 134 | console.log('Sending pause command:', pauseCommand); 135 | sendCommand(printer.id, pauseCommand); 136 | } else if (canResume) { 137 | // Send resume command 138 | const resumeCommand = { 139 | Id: '', 140 | Data: { 141 | Cmd: 131, 142 | Data: {}, 143 | RequestID: `resume-${Date.now()}`, 144 | MainboardID: '', 145 | TimeStamp: Date.now(), 146 | From: 1, 147 | }, 148 | }; 149 | console.log('Sending resume command:', resumeCommand); 150 | sendCommand(printer.id, resumeCommand); 151 | } 152 | }; 153 | 154 | const handleStopPrint = () => { 155 | if (!printer) return; 156 | 157 | Alert.alert( 158 | 'Cancel Print', 159 | `Are you sure you want to cancel the print? This action cannot be undone.`, 160 | [ 161 | { 162 | text: 'Cancel', 163 | style: 'cancel', 164 | }, 165 | { 166 | text: 'Stop Print', 167 | style: 'destructive', 168 | onPress: () => { 169 | // Send cancel command 170 | const cancelCommand = { 171 | Id: '', 172 | Data: { 173 | Cmd: 130, 174 | Data: {}, 175 | RequestID: `cancel-${Date.now()}`, 176 | MainboardID: '', 177 | TimeStamp: Date.now(), 178 | From: 1, 179 | }, 180 | }; 181 | console.log('Sending cancel command:', cancelCommand); 182 | sendCommand(printer.id, cancelCommand); 183 | }, 184 | }, 185 | ] 186 | ); 187 | }; 188 | 189 | const onRefresh = useCallback(() => { 190 | setRefreshing(true); 191 | reconnectAll(); 192 | // Keep the timeout to give visual feedback on the refresh control 193 | setTimeout(() => setRefreshing(false), 1000); 194 | handleWebViewTouchEnd(); 195 | }, [reconnectAll]); 196 | 197 | const handleLightToggle = (isOn: boolean) => { 198 | if (!printer) return; 199 | 200 | const command = { 201 | Id: `${printer.printerName}-id-${Date.now()}`, 202 | Data: { 203 | Cmd: 403, 204 | Data: { 205 | LightStatus: { 206 | SecondLight: isOn, 207 | }, 208 | }, 209 | RequestID: `light-toggle-${Date.now()}`, 210 | MainboardID: '', 211 | TimeStamp: 0, 212 | From: 1, 213 | }, 214 | }; 215 | 216 | sendCommand(printer.id, command); 217 | }; 218 | 219 | const handleFanToggle = ( 220 | fanType: 'model' | 'chamber' | 'side', 221 | isOn: boolean 222 | ) => { 223 | if (!printer) return; 224 | 225 | // Update the appropriate state 226 | switch (fanType) { 227 | case 'model': 228 | setModelFanState(isOn); 229 | break; 230 | case 'chamber': 231 | setChamberFanState(isOn); 232 | break; 233 | case 'side': 234 | setSideFanState(isOn); 235 | break; 236 | } 237 | 238 | // Create the command with all current fan states 239 | const command = { 240 | Id: `${printer.printerName}-id-${Date.now()}`, 241 | Data: { 242 | Cmd: 403, 243 | Data: { 244 | TargetFanSpeed: { 245 | ModelFan: 246 | fanType === 'model' ? (isOn ? 100 : 0) : modelFanState ? 100 : 0, 247 | AuxiliaryFan: 248 | fanType === 'side' ? (isOn ? 100 : 0) : sideFanState ? 100 : 0, 249 | BoxFan: 250 | fanType === 'chamber' 251 | ? isOn 252 | ? 100 253 | : 0 254 | : chamberFanState 255 | ? 100 256 | : 0, 257 | }, 258 | }, 259 | RequestID: `${fanType}-fan-toggle-${Date.now()}`, 260 | MainboardID: '', 261 | TimeStamp: 0, 262 | From: 1, 263 | }, 264 | }; 265 | 266 | sendCommand(printer.id, command); 267 | }; 268 | 269 | const handleDeletePrinter = () => { 270 | if (!printer) return; 271 | 272 | Alert.alert( 273 | 'Delete Printer', 274 | `Are you sure you want to delete ${printer.printerName}? This action cannot be undone.`, 275 | [ 276 | { 277 | text: 'Cancel', 278 | style: 'cancel', 279 | }, 280 | { 281 | text: 'Delete', 282 | style: 'destructive', 283 | onPress: () => { 284 | removePrinter(printer.id); 285 | navigation.goBack(); 286 | }, 287 | }, 288 | ] 289 | ); 290 | }; 291 | 292 | if (!printer) { 293 | return ( 294 | 298 |
299 | 300 | 301 | Could not find the specified printer. 302 | 303 | 307 | Go Back 308 | 309 | 310 | 311 | ); 312 | } 313 | 314 | // Get light status from printer data 315 | const isLightOn = printer.status?.LightStatus?.SecondLight === 1; 316 | 317 | // Get fan status from printer data and sync with local state 318 | const isModelFanOn = modelFanState; 319 | const isChamberFanOn = chamberFanState; 320 | const isSideFanOn = sideFanState; 321 | 322 | return ( 323 | 327 | {!isFullScreen && ( 328 |
332 | )} 333 | 334 | 338 | } 339 | scrollEventThrottle={16} 340 | showsVerticalScrollIndicator={false} 341 | scrollEnabled={!isWebViewInteracting} 342 | > 343 | 344 | {/* Back Button */} 345 | 349 | 350 | 351 | BACK 352 | 353 | 354 | 355 | {/* Video Stream WebView */} 356 | 361 | {printer.videoUrl ? ( 362 | <> 363 | { 386 | const { nativeEvent } = syntheticEvent; 387 | console.warn('WebView error: ', nativeEvent); 388 | }} 389 | onHttpError={syntheticEvent => { 390 | const { nativeEvent } = syntheticEvent; 391 | console.warn('WebView HTTP error: ', nativeEvent); 392 | }} 393 | /> 394 | 398 | 399 | 400 | 401 | ) : ( 402 | 403 | 404 | 405 | Video feed not available 406 | 407 | 408 | Waiting for video stream... 409 | 410 | 411 | )} 412 | 413 | 414 | {/* Main Printer Card */} 415 | 422 | {/* Printer Controls Card */} 423 | 424 | 425 | CONTROLS 426 | 427 | {/* Light Toggle */} 428 | 429 | 430 | 435 | 436 | Printer Light 437 | 438 | 439 | 445 | 446 | 447 | {/* Chamber Fan Toggle */} 448 | 449 | 450 | 455 | 456 | Chamber Fan 457 | 458 | 459 | handleFanToggle('chamber', isOn)} 462 | trackColor={{ false: '#D1D5DB', true: '#DBEAFE' }} 463 | thumbColor={isChamberFanOn ? '#3B82F6' : '#9CA3AF'} 464 | /> 465 | 466 | 467 | {/* Model Fan Toggle */} 468 | 469 | 470 | 475 | 476 | Model Fan 477 | 478 | 479 | handleFanToggle('model', isOn)} 482 | trackColor={{ false: '#D1D5DB', true: '#DBEAFE' }} 483 | thumbColor={isModelFanOn ? '#3B82F6' : '#9CA3AF'} 484 | /> 485 | 486 | 487 | {/* Side Fan Toggle */} 488 | 489 | 490 | 495 | 496 | Side Fan 497 | 498 | 499 | handleFanToggle('side', isOn)} 502 | trackColor={{ false: '#D1D5DB', true: '#DBEAFE' }} 503 | thumbColor={isSideFanOn ? '#3B82F6' : '#9CA3AF'} 504 | /> 505 | 506 | 507 | 508 | 509 | 510 | PRINTER SETTINGS 511 | 512 | {/* Delete Printer */} 513 | 514 | 515 | 516 | 517 | Unlink Printer 518 | 519 | 520 | 525 | UNLINK 526 | 527 | 528 | 529 | 530 | 531 | 532 | {/* Full-screen Modal */} 533 | 538 | 539 | 543 | 544 | 545 | 555 | 556 | 557 | 558 | 559 | 560 | `, 561 | }} 562 | style={{ 563 | width: Dimensions.get('window').height, 564 | height: Dimensions.get('window').width, 565 | transform: [{ rotate: '90deg' }], 566 | }} 567 | scalesPageToFit={true} 568 | scrollEnabled={true} 569 | bounces={false} 570 | showsHorizontalScrollIndicator={false} 571 | showsVerticalScrollIndicator={false} 572 | /> 573 | 577 | 578 | 579 | 580 | 581 | 582 | ); 583 | }; 584 | -------------------------------------------------------------------------------- /src/screens/SettingsScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | View, 4 | Text, 5 | ScrollView, 6 | TouchableOpacity, 7 | Alert, 8 | Linking, 9 | } from 'react-native'; 10 | import { SafeAreaView } from 'react-native-safe-area-context'; 11 | import { Header } from '../components/Header'; 12 | import { Ionicons } from '@expo/vector-icons'; 13 | import { useTheme } from '../contexts/ThemeContext'; 14 | import AsyncStorage from '@react-native-async-storage/async-storage'; 15 | import Constants from 'expo-constants'; 16 | 17 | export const SettingsScreen = () => { 18 | const { theme, setTheme } = useTheme(); 19 | 20 | const handleThemePress = () => { 21 | Alert.alert('Select Theme', 'Choose your preferred theme', [ 22 | { 23 | text: 'Light', 24 | onPress: () => setTheme('light'), 25 | }, 26 | { 27 | text: 'Dark', 28 | onPress: () => setTheme('dark'), 29 | }, 30 | { 31 | text: 'System', 32 | onPress: () => setTheme('system'), 33 | }, 34 | { 35 | text: 'Cancel', 36 | style: 'cancel', 37 | }, 38 | ]); 39 | }; 40 | 41 | const handleGitHubPress = async () => { 42 | const url = 'https://github.com/WalkerFrederick/CC-Tool'; 43 | try { 44 | const supported = await Linking.canOpenURL(url); 45 | if (supported) { 46 | await Linking.openURL(url); 47 | } else { 48 | Alert.alert('Error', 'Cannot open GitHub link'); 49 | } 50 | } catch (error) { 51 | Alert.alert('Error', 'Failed to open GitHub link'); 52 | } 53 | }; 54 | 55 | const handleReportIssuesPress = async () => { 56 | const url = 'https://github.com/WalkerFrederick/CC-Tool/issues'; 57 | try { 58 | const supported = await Linking.canOpenURL(url); 59 | if (supported) { 60 | await Linking.openURL(url); 61 | } else { 62 | Alert.alert('Error', 'Cannot open GitHub Issues link'); 63 | } 64 | } catch (error) { 65 | Alert.alert('Error', 'Failed to open GitHub Issues link'); 66 | } 67 | }; 68 | 69 | const handleResetWelcomeFlow = () => { 70 | Alert.alert( 71 | 'Reset Welcome Flow', 72 | 'Are you sure you want to reset the welcome flow? You will see it again the next time you open the app.', 73 | [ 74 | { 75 | text: 'Cancel', 76 | style: 'cancel', 77 | }, 78 | { 79 | text: 'Reset', 80 | style: 'destructive', 81 | onPress: async () => { 82 | try { 83 | await AsyncStorage.removeItem('hasLaunched'); 84 | Alert.alert('Success', 'The welcome flow has been reset.'); 85 | } catch (error) { 86 | Alert.alert('Error', 'Failed to reset the welcome flow.'); 87 | } 88 | }, 89 | }, 90 | ] 91 | ); 92 | }; 93 | 94 | const handleLicensePress = async () => { 95 | const url = 'https://github.com/WalkerFrederick/CC-Tool/blob/main/LICENSE'; 96 | try { 97 | const supported = await Linking.canOpenURL(url); 98 | if (supported) { 99 | await Linking.openURL(url); 100 | } else { 101 | Alert.alert('Error', 'Cannot open license link'); 102 | } 103 | } catch (error) { 104 | Alert.alert('Error', 'Failed to open license link'); 105 | } 106 | }; 107 | 108 | return ( 109 | 113 |
114 | 119 | 120 | 121 | 125 | 126 | 127 | 128 | 129 | Theme 130 | 131 | 132 | Light mode / Dark mode 133 | 134 | 135 | 136 | 137 | 138 | {theme} 139 | 140 | 141 | 142 | 143 | 147 | 148 | 149 | 150 | 151 | Reset Welcome Flow 152 | 153 | 154 | Show the welcome screen on next launch 155 | 156 | 157 | 158 | 159 | 160 | 164 | 165 | 166 | 167 | 168 | Github 169 | 170 | 171 | View Source and Contribute 172 | 173 | 174 | 175 | 176 | 177 | 181 | 182 | 183 | 184 | 185 | Report Issues 186 | 187 | 188 | Report bugs and request features 189 | 190 | 191 | 192 | 193 | 194 | 198 | 199 | 200 | 201 | 202 | License 203 | 204 | 205 | MIT License 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | App Version 218 | 219 | 220 | V{Constants.expoConfig?.version || '1.0.1'} 221 | 222 | 223 | 224 | 225 | 226 | {/* Disclaimer */} 227 | 228 | 229 | 235 | 236 | This is an unofficial companion app for some SDCP-based 237 | printers. We are not associated with any manufacturers. 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | ); 246 | }; 247 | -------------------------------------------------------------------------------- /src/screens/WelcomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | View, 4 | Text, 5 | TouchableOpacity, 6 | StyleSheet, 7 | SafeAreaView, 8 | Image, 9 | Linking, 10 | Alert, 11 | } from 'react-native'; 12 | import PagerView from 'react-native-pager-view'; 13 | import { Ionicons } from '@expo/vector-icons'; 14 | import { useTheme } from '../contexts/ThemeContext'; 15 | import { StatusBar } from 'expo-status-bar'; 16 | 17 | const WelcomeScreen = ({ onComplete }: { onComplete: () => void }) => { 18 | const { colorScheme } = useTheme(); 19 | const isDarkMode = colorScheme === 'dark'; 20 | const styles = getStyles(isDarkMode); 21 | 22 | const pagerRef = React.useRef(null); 23 | const [page, setPage] = React.useState(0); 24 | 25 | const handleNext = () => { 26 | if (page < 2) { 27 | pagerRef.current?.setPage(page + 1); 28 | } else { 29 | onComplete(); 30 | } 31 | }; 32 | 33 | const handleBack = () => { 34 | if (page > 0) { 35 | pagerRef.current?.setPage(page - 1); 36 | } 37 | }; 38 | 39 | const handleGitHubPress = async () => { 40 | const url = 'https://github.com/WalkerFrederick/CC-Tool'; 41 | try { 42 | const supported = await Linking.canOpenURL(url); 43 | if (supported) { 44 | await Linking.openURL(url); 45 | } else { 46 | Alert.alert('Error', 'Cannot open GitHub link'); 47 | } 48 | } catch (error) { 49 | Alert.alert('Error', 'Failed to open GitHub link'); 50 | } 51 | }; 52 | 53 | return ( 54 | 55 | 56 | setPage(e.nativeEvent.position)} 61 | scrollEnabled={true} 62 | > 63 | 64 | 74 | 75 | Welcome to CC Tool 76 | 77 | 78 | The Open Source 79 | 80 | 81 | Companion app for the CC 82 | 83 | 84 | 85 | 91 | 92 | Quick Disclaimer 93 | 94 | 95 | We are not 96 | 97 | 98 | associated with 99 | 100 | 101 | any manufacturer. 102 | 103 | 104 | This is a community project. 105 | 106 | 107 | 108 | 114 | 115 | Show Support, 116 | 117 | 118 | Get Help, 119 | 120 | 121 | Request Features, 122 | 123 | 124 | Contribute 125 | 126 | 127 | 131 | 136 | 137 | Visit on GitHub 138 | 139 | 140 | 141 | 142 | 143 | {[...Array(3).keys()].map(i => ( 144 | 154 | ))} 155 | 156 | 157 | {page > 0 && ( 158 | 159 | Back 160 | 161 | )} 162 | 163 | 164 | {page === 2 ? 'Finish' : 'Next'} 165 | 166 | 167 | 168 | 169 | ); 170 | }; 171 | 172 | const getStyles = (isDarkMode: boolean) => 173 | StyleSheet.create({ 174 | container: { 175 | flex: 1, 176 | backgroundColor: isDarkMode ? '#1F2937' : '#F3F4F6', 177 | }, 178 | pagerView: { 179 | flex: 1, 180 | }, 181 | page: { 182 | justifyContent: 'center', 183 | alignItems: 'center', 184 | padding: 30, 185 | }, 186 | title: { 187 | fontSize: 28, 188 | fontWeight: 'bold', 189 | color: isDarkMode ? '#F9FAFB' : '#1F2937', 190 | marginTop: 20, 191 | textAlign: 'center', 192 | }, 193 | subtitle: { 194 | fontSize: 18, 195 | color: isDarkMode ? '#D1D5DB' : '#4B5563', 196 | marginTop: 10, 197 | textAlign: 'center', 198 | }, 199 | description: { 200 | fontSize: 16, 201 | color: isDarkMode ? '#9CA3AF' : '#6B7280', 202 | marginTop: 20, 203 | textAlign: 'center', 204 | lineHeight: 24, 205 | }, 206 | indicatorContainer: { 207 | flexDirection: 'row', 208 | justifyContent: 'center', 209 | alignItems: 'center', 210 | padding: 20, 211 | }, 212 | indicator: { 213 | height: 10, 214 | width: 10, 215 | borderRadius: 5, 216 | marginHorizontal: 5, 217 | }, 218 | buttonContainer: { 219 | flexDirection: 'row', 220 | justifyContent: 'space-between', 221 | paddingHorizontal: 20, 222 | paddingBottom: 20, 223 | }, 224 | nextButton: { 225 | backgroundColor: '#3B82F6', 226 | paddingVertical: 15, 227 | borderRadius: 10, 228 | alignItems: 'center', 229 | flex: 1, 230 | marginLeft: 5, 231 | }, 232 | backButton: { 233 | backgroundColor: '#6B7280', 234 | paddingVertical: 15, 235 | borderRadius: 10, 236 | alignItems: 'center', 237 | flex: 1, 238 | marginRight: 5, 239 | }, 240 | buttonText: { 241 | color: '#FFFFFF', 242 | fontSize: 16, 243 | fontWeight: 'bold', 244 | }, 245 | }); 246 | 247 | export default WelcomeScreen; 248 | -------------------------------------------------------------------------------- /src/screens/WhereIsIpScreen.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, TouchableOpacity, SafeAreaView } from 'react-native'; 2 | import { useNavigation } from '@react-navigation/native'; 3 | 4 | const WhereIsIpScreen = () => { 5 | const navigation = useNavigation(); 6 | 7 | return ( 8 | 9 | 10 | 11 | How to Find Your Printer's IP Address 12 | 13 | 14 | On the CC, you can find your printer's IP address by clicking the 15 | "Settings" button. and pressing "Network" at the 16 | top. 17 | 18 | 19 | Ensure your phone is connected to the same Wi-Fi network as your 20 | printer! 21 | 22 | 23 | 24 | navigation.goBack()} 26 | className="bg-blue-500 rounded-lg py-4" 27 | > 28 | 29 | Add Printer 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default WhereIsIpScreen; 38 | -------------------------------------------------------------------------------- /src/screens/index.ts: -------------------------------------------------------------------------------- 1 | export { HomeScreen } from './HomeScreen'; 2 | export { SettingsScreen } from './SettingsScreen'; 3 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Common types used throughout the app 2 | 3 | export interface User { 4 | id: string; 5 | name: string; 6 | email: string; 7 | avatar?: string; 8 | } 9 | 10 | export interface ApiResponse { 11 | data: T; 12 | success: boolean; 13 | message?: string; 14 | } 15 | 16 | export interface NavigationProps { 17 | navigation: any; 18 | route: any; 19 | } 20 | 21 | // Theme types 22 | export type Theme = 'light' | 'dark' | 'system'; 23 | 24 | export interface ThemeContextType { 25 | theme: Theme; 26 | setTheme: (theme: Theme) => void; 27 | isDark: boolean; 28 | } 29 | 30 | // Printer types 31 | export type ConnectionStatus = 32 | | 'connected' 33 | | 'connecting' 34 | | 'disconnected' 35 | | 'error' 36 | | 'timeout'; 37 | 38 | export interface CurrentFanSpeed { 39 | ModelFan: number; 40 | AuxiliaryFan: number; 41 | BoxFan: number; 42 | } 43 | 44 | export interface LightStatus { 45 | SecondLight: number; 46 | RgbLight: [number, number, number]; 47 | } 48 | 49 | export interface PrintInfo { 50 | Status: number; 51 | CurrentLayer: number; 52 | TotalLayer: number; 53 | CurrentTicks: number; 54 | TotalTicks: number; 55 | Filename: string; 56 | TaskId: string; 57 | PrintSpeedPct: number; 58 | Progress: number; 59 | } 60 | 61 | export type PrintStatus = 62 | | 'idle' 63 | | 'completed' 64 | | 'preparing' 65 | | 'printing' 66 | | 'paused' 67 | | 'stopped' 68 | | 'unknown'; 69 | 70 | export const getPrintStatus = (status: number): PrintStatus => { 71 | switch (status) { 72 | case 0: 73 | return 'idle'; 74 | case 6: 75 | return 'paused'; 76 | case 8: 77 | return 'stopped'; 78 | case 9: 79 | return 'completed'; 80 | case 16: 81 | case 1: 82 | case 20: 83 | return 'preparing'; 84 | case 13: 85 | return 'printing'; 86 | default: 87 | return 'unknown'; 88 | } 89 | }; 90 | 91 | export const isPausable = (status: number): boolean => { 92 | // Can pause when printing 93 | return status === 13; 94 | }; 95 | 96 | export const isResumable = (status: number): boolean => { 97 | // Can resume when paused 98 | return status === 6; 99 | }; 100 | 101 | export const isStoppable = (status: number): boolean => { 102 | // Can stop when printing, preparing, or paused 103 | return status === 6; 104 | }; 105 | 106 | export interface PrinterStatus { 107 | CurrentStatus: number[]; 108 | TimeLapseStatus: number; 109 | PlatFormType: number; 110 | TempOfHotbed: number; 111 | TempOfNozzle: number; 112 | TempOfBox: number; 113 | TempTargetHotbed: number; 114 | TempTargetNozzle: number; 115 | TempTargetBox: number; 116 | CurrenCoord: string; 117 | CurrentFanSpeed: CurrentFanSpeed; 118 | ZOffset: number; 119 | LightStatus: LightStatus; 120 | PrintInfo: PrintInfo; 121 | } 122 | 123 | export interface Printer { 124 | id: string; 125 | printerName: string; 126 | ipAddress: string; 127 | connectionStatus: ConnectionStatus; 128 | status?: PrinterStatus; 129 | lastUpdate?: number; // Timestamp of last status update 130 | videoUrl?: string; // Video stream URL 131 | } 132 | -------------------------------------------------------------------------------- /src/utils/FormatUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format utilities for common text formatting operations 3 | */ 4 | 5 | /** 6 | * Formats a timestamp into a human-readable "time ago" string 7 | * @param timestamp - The timestamp to format (in milliseconds) 8 | * @param currentTime - The current time (in milliseconds) 9 | * @returns A formatted string like "2m 30s ago" or "Just now" 10 | * 11 | * @example 12 | * const timeAgo = formatTimeAgo(Date.now() - 90000, Date.now()); // "1m 30s ago" 13 | * const justNow = formatTimeAgo(Date.now() - 2000, Date.now()); // "Just now" 14 | */ 15 | export const formatTimeAgo = ( 16 | timestamp: number, 17 | currentTime: number 18 | ): string => { 19 | const diff = currentTime - timestamp; 20 | const minutes = Math.floor(diff / (1000 * 60)); 21 | const seconds = Math.floor(diff / 1000); 22 | 23 | if (minutes > 0) { 24 | return `${minutes}m ${seconds % 60}s ago`; 25 | } else if (seconds > 5) { 26 | return `${seconds}s ago`; 27 | } else { 28 | return 'Just now'; 29 | } 30 | }; 31 | 32 | /** 33 | * Truncates text to a maximum length and adds ellipsis if needed 34 | * @param text - The text to truncate 35 | * @param maxLength - The maximum length before truncation 36 | * @returns The truncated text with ellipsis if needed 37 | * 38 | * @example 39 | * const short = formatTextMaxEllipsis("Hello World", 20); // "Hello World" 40 | * const long = formatTextMaxEllipsis("This is a very long text that needs truncation", 20); // "This is a very long..." 41 | */ 42 | export const formatTextMaxEllipsis = ( 43 | text: string, 44 | maxLength: number 45 | ): string => { 46 | if (!text || text.length <= maxLength) { 47 | return text; 48 | } 49 | 50 | return text.substring(0, maxLength) + '...'; 51 | }; 52 | 53 | /** 54 | * Converts seconds to a human-readable time format (hours and minutes only) 55 | * @param seconds - The number of seconds to format 56 | * @returns A formatted string like "2h30m", "45m", or "0m" 57 | * 58 | * @example 59 | * const hours = formatTicksToReadableTime(9000); // "2h30m" 60 | * const minutes = formatTicksToReadableTime(2700); // "45m" 61 | * const short = formatTicksToReadableTime(30); // "0m" 62 | */ 63 | export const formatTicksToReadableTime = (seconds: number): string => { 64 | if (!seconds || seconds <= 0) { 65 | return '0m'; 66 | } 67 | 68 | const hours = Math.floor(seconds / 3600); 69 | const minutes = Math.floor((seconds % 3600) / 60); 70 | 71 | if (hours > 0) { 72 | if (minutes > 0) { 73 | return `${hours}h${minutes}m`; 74 | } else { 75 | return `${hours}h`; 76 | } 77 | } else { 78 | return `${minutes}m`; 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/utils/LocalNetworkUtils.ts: -------------------------------------------------------------------------------- 1 | import { Platform, Alert, Linking } from 'react-native'; 2 | import { 3 | checkLocalNetworkAccess, 4 | requestLocalNetworkAccess, 5 | } from '@generac/react-native-local-network-permission'; 6 | 7 | export const checkLocalNetworkPermission = async (): Promise => { 8 | if (Platform.OS !== 'ios') { 9 | return true; // No permission needed on Android 10 | } 11 | 12 | try { 13 | // First, request local network access to ensure the dialog appears 14 | await requestLocalNetworkAccess(); 15 | 16 | // Then check if we have access 17 | const hasAccess = await checkLocalNetworkAccess(); 18 | 19 | if (!hasAccess) { 20 | Alert.alert( 21 | 'Local Network Access Required', 22 | 'This app needs access to your local network to connect to printers. Please enable local network access in Settings to continue.', 23 | [ 24 | { 25 | text: 'Cancel', 26 | style: 'cancel', 27 | }, 28 | { 29 | text: 'Open Settings', 30 | onPress: () => { 31 | Linking.openSettings(); 32 | }, 33 | }, 34 | ] 35 | ); 36 | return false; 37 | } 38 | 39 | return true; 40 | } catch (error) { 41 | console.error('Error checking local network access:', error); 42 | Alert.alert( 43 | 'Network Access Error', 44 | 'Unable to verify local network access. Please ensure you have granted the necessary permissions.', 45 | [{ text: 'OK' }] 46 | ); 47 | return false; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: ['./App.{js,ts,tsx}', './src/**/*.{js,ts,tsx}'], 5 | 6 | presets: [require('nativewind/preset')], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "jsx": "react-jsx", 6 | 7 | "baseUrl": ".", 8 | "paths": { 9 | "~/*": ["src/*"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------