├── .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 | 
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 |
40 | );
41 | }
42 |
43 | return (
44 |
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 |