Files
FEDEO/mobile/app/more/nimbot.tsx
2026-03-16 20:46:26 +01:00

411 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { renderPrintLabel } from '@/src/lib/api';
import {
connectNimbotDevice,
disconnectNimbotDevice,
getActiveNimbotConnection,
printNimbotEncodedLabel,
scanNimbotDevices,
} from '@/src/lib/nimbot';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
type ListedDevice = {
id: string;
name: string;
rssi: number | null;
};
export default function NimbotScreen() {
const { token } = useAuth();
const params = useLocalSearchParams<{
itemName?: string;
itemId?: string;
serial?: string;
customerName?: string;
customerInventoryId?: string;
serialNumber?: string;
}>();
const [devices, setDevices] = useState<ListedDevice[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [scanning, setScanning] = useState(false);
const [connectingId, setConnectingId] = useState<string | null>(null);
const [printing, setPrinting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [manualLabelText, setManualLabelText] = useState('');
const activeConnection = getActiveNimbotConnection();
const normalizedInventoryId = useMemo(() => String(params.customerInventoryId || params.itemId || '').trim(), [params.customerInventoryId, params.itemId]);
const normalizedSerial = useMemo(() => String(params.serialNumber || params.serial || '').trim(), [params.serial, params.serialNumber]);
const prefilledText = useMemo(() => {
const parts = [params.itemName, normalizedInventoryId ? `ID: ${normalizedInventoryId}` : null, normalizedSerial ? `SN: ${normalizedSerial}` : null]
.filter(Boolean)
.map((value) => String(value));
return parts.join(' | ');
}, [normalizedInventoryId, normalizedSerial, params.itemName]);
useEffect(() => {
if (!prefilledText) return;
setManualLabelText(prefilledText);
}, [prefilledText]);
const loadDevices = useCallback(async () => {
setError(null);
setInfo(null);
setScanning(true);
try {
const result = await scanNimbotDevices();
setDevices(result);
if (result.length === 0) {
setInfo('Kein Nimbot gefunden. Drucker einschalten und nah an das iPhone halten.');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Nimbot-Suche fehlgeschlagen.');
} finally {
setScanning(false);
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
void loadDevices();
}, [loadDevices]);
async function onRefresh() {
setRefreshing(true);
await loadDevices();
}
async function onConnect(deviceId: string) {
setConnectingId(deviceId);
setError(null);
setInfo(null);
try {
const connected = await connectNimbotDevice(deviceId);
setInfo(`Verbunden mit ${connected.device.name}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.');
} finally {
setConnectingId(null);
}
}
async function onDisconnect() {
setError(null);
setInfo(null);
try {
await disconnectNimbotDevice();
setInfo('Verbindung getrennt.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Trennen fehlgeschlagen.');
}
}
async function onPrint() {
setError(null);
setInfo(null);
setPrinting(true);
try {
if (!token) {
throw new Error('Nicht angemeldet. Bitte erneut einloggen.');
}
const text = manualLabelText.trim();
if (!text) {
throw new Error('Bitte Label-Inhalt eingeben.');
}
const context: Record<string, unknown> = {
text,
name: params.itemName || text,
};
if (normalizedInventoryId) {
context.customerInventoryId = normalizedInventoryId;
}
if (normalizedSerial) {
context.serialNumber = normalizedSerial;
}
if (params.customerName) {
context.customerName = String(params.customerName);
}
const rendered = await renderPrintLabel(token, context, 584, 354);
await printNimbotEncodedLabel(rendered.encoded, {
density: 5,
copies: 1,
labelType: 1,
});
setInfo('Label wurde an den Nimbot gesendet.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Label konnte nicht gedruckt werden.');
} finally {
setPrinting(false);
}
}
const connectedId = activeConnection?.device.id || null;
return (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.card}>
<Text style={styles.title}>Nimbot M2</Text>
<Text style={styles.subtitle}>Bluetooth-Anbindung (Beta)</Text>
{connectedId ? (
<Text style={styles.connectedText}>Verbunden: {activeConnection?.device.name || connectedId}</Text>
) : (
<Text style={styles.disconnectedText}>Nicht verbunden</Text>
)}
<View style={styles.actionRow}>
<Pressable
style={[styles.primaryButton, scanning ? styles.buttonDisabled : null]}
onPress={() => void loadDevices()}
disabled={scanning}>
<Text style={styles.primaryButtonText}>{scanning ? 'Suche...' : 'Geräte suchen'}</Text>
</Pressable>
<Pressable
style={[styles.secondaryButton, !connectedId ? styles.buttonDisabled : null]}
onPress={() => void onDisconnect()}
disabled={!connectedId}>
<Text style={styles.secondaryButtonText}>Trennen</Text>
</Pressable>
</View>
<TextInput
value={manualLabelText}
onChangeText={setManualLabelText}
placeholder="Label-Inhalt für nächsten Druck"
placeholderTextColor="#9ca3af"
style={styles.input}
multiline
/>
<Pressable
style={[styles.primaryButton, (!connectedId || printing) ? styles.buttonDisabled : null]}
onPress={() => void onPrint()}
disabled={!connectedId || printing}>
<Text style={styles.primaryButtonText}>{printing ? 'Drucke...' : 'Label drucken'}</Text>
</Pressable>
{info ? <Text style={styles.info}>{info}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Gefundene Geräte</Text>
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Suche läuft...</Text>
</View>
) : null}
{!loading && devices.length === 0 ? <Text style={styles.empty}>Keine Geräte gefunden.</Text> : null}
{!loading &&
devices.map((device) => {
const isConnected = connectedId === device.id;
const isConnecting = connectingId === device.id;
return (
<View key={device.id} style={styles.deviceRow}>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName} numberOfLines={1}>
{device.name}
</Text>
<Text style={styles.deviceMeta} numberOfLines={1}>
{device.id}
{typeof device.rssi === 'number' ? ` · RSSI ${device.rssi}` : ''}
</Text>
</View>
<Pressable
style={[
styles.connectButton,
isConnected ? styles.connectButtonConnected : null,
isConnecting ? styles.buttonDisabled : null,
]}
onPress={() => void onConnect(device.id)}
disabled={isConnecting || isConnected}>
<Text style={styles.connectButtonText}>
{isConnected ? 'Verbunden' : isConnecting ? 'Verbinde...' : 'Verbinden'}
</Text>
</Pressable>
</View>
);
})}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 12,
backgroundColor: '#f9fafb',
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
title: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
subtitle: {
color: '#6b7280',
fontSize: 13,
},
connectedText: {
color: '#166534',
fontSize: 13,
fontWeight: '600',
},
disconnectedText: {
color: '#6b7280',
fontSize: 13,
},
actionRow: {
flexDirection: 'row',
gap: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
minHeight: 72,
textAlignVertical: 'top',
color: '#111827',
backgroundColor: '#ffffff',
},
primaryButton: {
minHeight: 40,
borderRadius: 10,
backgroundColor: PRIMARY,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 13,
},
secondaryButton: {
minHeight: 40,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
fontSize: 13,
},
sectionTitle: {
color: '#111827',
fontSize: 16,
fontWeight: '700',
},
deviceRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
deviceInfo: {
flex: 1,
minWidth: 0,
},
deviceName: {
color: '#111827',
fontSize: 14,
fontWeight: '600',
},
deviceMeta: {
color: '#6b7280',
fontSize: 12,
marginTop: 2,
},
connectButton: {
minHeight: 36,
borderRadius: 9,
borderWidth: 1,
borderColor: PRIMARY,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#eff9ea',
},
connectButtonConnected: {
borderColor: '#9ca3af',
backgroundColor: '#f3f4f6',
},
connectButtonText: {
color: '#3d7a30',
fontSize: 12,
fontWeight: '700',
},
loadingBox: {
padding: 12,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
fontSize: 13,
paddingVertical: 4,
},
info: {
color: '#1d4ed8',
fontSize: 13,
},
error: {
color: '#dc2626',
fontSize: 13,
},
buttonDisabled: {
opacity: 0.6,
},
});