/** * Floating "?" button + bug report modal — wisi nad każdą stroną aplikacji. * * UX: * 1. Tap FAB → app robi screenshot CIEnte (przed otwarciem modal'u, więc nie * zawiera samego buttonu/modal'u) przez react-native-view-shot.captureScreen. * 2. Modal pokazuje preview screenshota + TextInput + opcję "wyślij bez screena" * + Send/Cancel. * 3. Send → POST /bug-reports z message + screen_name (z React Navigation focused * route) + scene_id (jeśli dostarczone w nav params) + base64 screenshot. * * Screenshot omija FLAG_SECURE bo to in-process render (View.draw → bitmap), * nie systemowy MediaProjection. */ import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native'; import React, { useCallback, useState } from 'react'; import { ActivityIndicator, Alert, Image, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import { captureScreen } from 'react-native-view-shot'; import { GoonClient } from '../api'; import { theme } from '../theme'; interface Props { client: GoonClient | null; appVersion: string; navRef: NavigationContainerRef; } // Screens na których FAB jest ukryty — Player ma fullscreen controls i FAB // nakłada się na progress bar / fullscreen button (bug #f53e50b9 screenshot // pokazał "?" zasłaniający duration label). const FAB_HIDDEN_SCREENS = new Set(['Player']); export function BugReportFAB({ client, appVersion, navRef }: Props) { const [open, setOpen] = useState(false); const [screenshot, setScreenshot] = useState(null); const [includeScreenshot, setIncludeScreenshot] = useState(true); const [message, setMessage] = useState(''); const [submitting, setSubmitting] = useState(false); // Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen. const [currentRoute, setCurrentRoute] = useState(null); React.useEffect(() => { if (!navRef) return; const unsub = navRef.addListener('state', () => { try { const r = navRef.isReady() ? navRef.getCurrentRoute() : null; setCurrentRoute(r?.name ?? null); } catch { setCurrentRoute(null); } }); return unsub; }, [navRef]); const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute); const onPress = useCallback(async () => { let captured: string | null = null; try { const dataUri = await captureScreen({ format: 'jpg', quality: 0.6, result: 'data-uri', }); captured = dataUri.split(',')[1] || null; } catch (e) { captured = null; } setScreenshot(captured); setIncludeScreenshot(captured !== null); setMessage(''); setOpen(true); }, []); const submit = useCallback(async () => { if (!client) { Alert.alert('Bug report', 'Brak połączenia z backendem.'); return; } if (!message.trim()) { Alert.alert('Bug report', 'Wpisz krótki opis.'); return; } setSubmitting(true); let routeName: string | null = null; let rawSceneId: string | undefined; try { const route = navRef.isReady() ? navRef.getCurrentRoute() : null; routeName = route?.name ?? null; const params = (route?.params ?? {}) as Record; rawSceneId = (params['sceneId'] as string | undefined) ?? (params['id'] as string | undefined); } catch { // navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable } const sceneId = rawSceneId && /^[0-9a-f-]{36}$/.test(rawSceneId) ? rawSceneId : null; try { await client.submitBugReport({ message: message.trim(), screen_name: routeName, app_version: appVersion, scene_id: sceneId, screenshot_b64: includeScreenshot ? screenshot : null, }); setOpen(false); setMessage(''); setScreenshot(null); Alert.alert('Bug report', 'Wysłano. Dzięki!'); } catch (e) { Alert.alert('Bug report', `Nie udało się wysłać: ${(e as Error).message}`); } finally { setSubmitting(false); } }, [client, message, screenshot, includeScreenshot, appVersion, navRef]); return ( <> {!hidden ? ( ? ) : null} setOpen(false)} > setOpen(false)} /> Zgłoś buga {screenshot ? ( Dołącz screenshot ) : ( Screenshot niedostępny (capture fail). Wyślę sam tekst. )} setOpen(false)} disabled={submitting} > Anuluj {submitting ? ( ) : ( Wyślij )} ); } const styles = StyleSheet.create({ fab: { position: 'absolute', right: 16, bottom: 24, width: 44, height: 44, borderRadius: 22, backgroundColor: theme.accentDeep, alignItems: 'center', justifyContent: 'center', elevation: 6, shadowColor: '#000', shadowOpacity: 0.4, shadowRadius: 6, shadowOffset: { width: 0, height: 3 }, opacity: 0.85, }, fabIcon: { color: theme.fg, fontSize: 22, fontWeight: '700', }, backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end', }, backdropTap: { flex: 1, }, sheet: { backgroundColor: theme.bgElevated, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 28, maxHeight: '90%', }, title: { color: theme.fg, fontSize: 18, fontWeight: '600', marginBottom: 12, }, preview: { marginBottom: 12, }, previewImg: { width: '100%', height: 180, borderRadius: 8, backgroundColor: theme.bg, }, toggleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, }, toggleLabel: { color: theme.muted, fontSize: 14, }, noScreenshot: { color: theme.warn, fontSize: 13, marginBottom: 12, textAlign: 'center', }, input: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 8, color: theme.fg, padding: 12, minHeight: 100, textAlignVertical: 'top', marginBottom: 16, }, btnRow: { flexDirection: 'row', gap: 12, }, btn: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, btnCancel: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, }, btnSend: { backgroundColor: theme.accent, }, btnText: { color: theme.fg, fontSize: 15, fontWeight: '600', }, });