react-native-bug-reporter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/android/build.gradle +49 -0
  4. package/android/src/main/AndroidManifest.xml +11 -0
  5. package/android/src/main/java/com/bugreporter/ScreenshotDetectorModule.kt +227 -0
  6. package/android/src/main/java/com/bugreporter/ScreenshotDetectorPackage.kt +20 -0
  7. package/ios/RNBugReporterScreenshot.m +11 -0
  8. package/ios/RNBugReporterScreenshot.swift +113 -0
  9. package/lib/commonjs/BugReporterProvider.js +139 -0
  10. package/lib/commonjs/BugReporterProvider.js.map +1 -0
  11. package/lib/commonjs/collectors/appInfo.js +21 -0
  12. package/lib/commonjs/collectors/appInfo.js.map +1 -0
  13. package/lib/commonjs/collectors/collectContext.js +45 -0
  14. package/lib/commonjs/collectors/collectContext.js.map +1 -0
  15. package/lib/commonjs/collectors/deviceInfo.js +41 -0
  16. package/lib/commonjs/collectors/deviceInfo.js.map +1 -0
  17. package/lib/commonjs/collectors/networkInfo.js +33 -0
  18. package/lib/commonjs/collectors/networkInfo.js.map +1 -0
  19. package/lib/commonjs/components/BugReportAdminScreen.js +225 -0
  20. package/lib/commonjs/components/BugReportAdminScreen.js.map +1 -0
  21. package/lib/commonjs/components/BugReportModal.js +341 -0
  22. package/lib/commonjs/components/BugReportModal.js.map +1 -0
  23. package/lib/commonjs/components/ScreenshotEditor.js +466 -0
  24. package/lib/commonjs/components/ScreenshotEditor.js.map +1 -0
  25. package/lib/commonjs/components/ScreenshotPreview.js +134 -0
  26. package/lib/commonjs/components/ScreenshotPreview.js.map +1 -0
  27. package/lib/commonjs/components/SeveritySelector.js +65 -0
  28. package/lib/commonjs/components/SeveritySelector.js.map +1 -0
  29. package/lib/commonjs/context/BugReporterContext.js +24 -0
  30. package/lib/commonjs/context/BugReporterContext.js.map +1 -0
  31. package/lib/commonjs/hooks/useScreenshotDetector.js +22 -0
  32. package/lib/commonjs/hooks/useScreenshotDetector.js.map +1 -0
  33. package/lib/commonjs/index.js +87 -0
  34. package/lib/commonjs/index.js.map +1 -0
  35. package/lib/commonjs/native/ScreenshotDetector.js +72 -0
  36. package/lib/commonjs/native/ScreenshotDetector.js.map +1 -0
  37. package/lib/commonjs/navigation/screenTracker.js +47 -0
  38. package/lib/commonjs/navigation/screenTracker.js.map +1 -0
  39. package/lib/commonjs/package.json +1 -0
  40. package/lib/commonjs/services/bugReportService.js +61 -0
  41. package/lib/commonjs/services/bugReportService.js.map +1 -0
  42. package/lib/commonjs/services/supabaseService.js +166 -0
  43. package/lib/commonjs/services/supabaseService.js.map +1 -0
  44. package/lib/commonjs/theme.js +28 -0
  45. package/lib/commonjs/theme.js.map +1 -0
  46. package/lib/commonjs/types.js +35 -0
  47. package/lib/commonjs/types.js.map +1 -0
  48. package/lib/commonjs/utils/logger.js +29 -0
  49. package/lib/commonjs/utils/logger.js.map +1 -0
  50. package/lib/module/BugReporterProvider.js +134 -0
  51. package/lib/module/BugReporterProvider.js.map +1 -0
  52. package/lib/module/collectors/appInfo.js +16 -0
  53. package/lib/module/collectors/appInfo.js.map +1 -0
  54. package/lib/module/collectors/collectContext.js +41 -0
  55. package/lib/module/collectors/collectContext.js.map +1 -0
  56. package/lib/module/collectors/deviceInfo.js +37 -0
  57. package/lib/module/collectors/deviceInfo.js.map +1 -0
  58. package/lib/module/collectors/networkInfo.js +29 -0
  59. package/lib/module/collectors/networkInfo.js.map +1 -0
  60. package/lib/module/components/BugReportAdminScreen.js +221 -0
  61. package/lib/module/components/BugReportAdminScreen.js.map +1 -0
  62. package/lib/module/components/BugReportModal.js +337 -0
  63. package/lib/module/components/BugReportModal.js.map +1 -0
  64. package/lib/module/components/ScreenshotEditor.js +461 -0
  65. package/lib/module/components/ScreenshotEditor.js.map +1 -0
  66. package/lib/module/components/ScreenshotPreview.js +130 -0
  67. package/lib/module/components/ScreenshotPreview.js.map +1 -0
  68. package/lib/module/components/SeveritySelector.js +61 -0
  69. package/lib/module/components/SeveritySelector.js.map +1 -0
  70. package/lib/module/context/BugReporterContext.js +19 -0
  71. package/lib/module/context/BugReporterContext.js.map +1 -0
  72. package/lib/module/hooks/useScreenshotDetector.js +18 -0
  73. package/lib/module/hooks/useScreenshotDetector.js.map +1 -0
  74. package/lib/module/index.js +32 -0
  75. package/lib/module/index.js.map +1 -0
  76. package/lib/module/native/ScreenshotDetector.js +68 -0
  77. package/lib/module/native/ScreenshotDetector.js.map +1 -0
  78. package/lib/module/navigation/screenTracker.js +41 -0
  79. package/lib/module/navigation/screenTracker.js.map +1 -0
  80. package/lib/module/services/bugReportService.js +57 -0
  81. package/lib/module/services/bugReportService.js.map +1 -0
  82. package/lib/module/services/supabaseService.js +159 -0
  83. package/lib/module/services/supabaseService.js.map +1 -0
  84. package/lib/module/theme.js +23 -0
  85. package/lib/module/theme.js.map +1 -0
  86. package/lib/module/types.js +31 -0
  87. package/lib/module/types.js.map +1 -0
  88. package/lib/module/utils/logger.js +25 -0
  89. package/lib/module/utils/logger.js.map +1 -0
  90. package/lib/typescript/src/BugReporterProvider.d.ts +18 -0
  91. package/lib/typescript/src/BugReporterProvider.d.ts.map +1 -0
  92. package/lib/typescript/src/collectors/appInfo.d.ts +6 -0
  93. package/lib/typescript/src/collectors/appInfo.d.ts.map +1 -0
  94. package/lib/typescript/src/collectors/collectContext.d.ts +7 -0
  95. package/lib/typescript/src/collectors/collectContext.d.ts.map +1 -0
  96. package/lib/typescript/src/collectors/deviceInfo.d.ts +7 -0
  97. package/lib/typescript/src/collectors/deviceInfo.d.ts.map +1 -0
  98. package/lib/typescript/src/collectors/networkInfo.d.ts +6 -0
  99. package/lib/typescript/src/collectors/networkInfo.d.ts.map +1 -0
  100. package/lib/typescript/src/components/BugReportAdminScreen.d.ts +11 -0
  101. package/lib/typescript/src/components/BugReportAdminScreen.d.ts.map +1 -0
  102. package/lib/typescript/src/components/BugReportModal.d.ts +20 -0
  103. package/lib/typescript/src/components/BugReportModal.d.ts.map +1 -0
  104. package/lib/typescript/src/components/ScreenshotEditor.d.ts +16 -0
  105. package/lib/typescript/src/components/ScreenshotEditor.d.ts.map +1 -0
  106. package/lib/typescript/src/components/ScreenshotPreview.d.ts +11 -0
  107. package/lib/typescript/src/components/ScreenshotPreview.d.ts.map +1 -0
  108. package/lib/typescript/src/components/SeveritySelector.d.ts +10 -0
  109. package/lib/typescript/src/components/SeveritySelector.d.ts.map +1 -0
  110. package/lib/typescript/src/context/BugReporterContext.d.ts +20 -0
  111. package/lib/typescript/src/context/BugReporterContext.d.ts.map +1 -0
  112. package/lib/typescript/src/hooks/useScreenshotDetector.d.ts +7 -0
  113. package/lib/typescript/src/hooks/useScreenshotDetector.d.ts.map +1 -0
  114. package/lib/typescript/src/index.d.ts +26 -0
  115. package/lib/typescript/src/index.d.ts.map +1 -0
  116. package/lib/typescript/src/native/ScreenshotDetector.d.ts +15 -0
  117. package/lib/typescript/src/native/ScreenshotDetector.d.ts.map +1 -0
  118. package/lib/typescript/src/navigation/screenTracker.d.ts +7 -0
  119. package/lib/typescript/src/navigation/screenTracker.d.ts.map +1 -0
  120. package/lib/typescript/src/services/bugReportService.d.ts +17 -0
  121. package/lib/typescript/src/services/bugReportService.d.ts.map +1 -0
  122. package/lib/typescript/src/services/supabaseService.d.ts +38 -0
  123. package/lib/typescript/src/services/supabaseService.d.ts.map +1 -0
  124. package/lib/typescript/src/theme.d.ts +7 -0
  125. package/lib/typescript/src/theme.d.ts.map +1 -0
  126. package/lib/typescript/src/types.d.ts +144 -0
  127. package/lib/typescript/src/types.d.ts.map +1 -0
  128. package/lib/typescript/src/utils/logger.d.ts +7 -0
  129. package/lib/typescript/src/utils/logger.d.ts.map +1 -0
  130. package/package.json +100 -0
  131. package/react-native-bug-reporter.podspec +22 -0
  132. package/react-native.config.js +18 -0
  133. package/src/BugReporterProvider.tsx +178 -0
  134. package/src/collectors/appInfo.ts +15 -0
  135. package/src/collectors/collectContext.ts +47 -0
  136. package/src/collectors/deviceInfo.ts +51 -0
  137. package/src/collectors/networkInfo.ts +31 -0
  138. package/src/components/BugReportAdminScreen.tsx +160 -0
  139. package/src/components/BugReportModal.tsx +315 -0
  140. package/src/components/ScreenshotEditor.tsx +410 -0
  141. package/src/components/ScreenshotPreview.tsx +98 -0
  142. package/src/components/SeveritySelector.tsx +59 -0
  143. package/src/context/BugReporterContext.ts +29 -0
  144. package/src/hooks/useScreenshotDetector.ts +20 -0
  145. package/src/index.ts +51 -0
  146. package/src/native/ScreenshotDetector.ts +87 -0
  147. package/src/navigation/screenTracker.ts +40 -0
  148. package/src/services/bugReportService.ts +81 -0
  149. package/src/services/supabaseService.ts +195 -0
  150. package/src/theme.ts +23 -0
  151. package/src/types.ts +156 -0
  152. package/src/utils/logger.ts +24 -0
@@ -0,0 +1,178 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import type {
9
+ BugReportInput,
10
+ BugReporterConfig,
11
+ CollectedContext,
12
+ ScreenshotData,
13
+ SubmitState,
14
+ } from './types';
15
+ import { BugReporterContext } from './context/BugReporterContext';
16
+ import { useScreenshotDetector } from './hooks/useScreenshotDetector';
17
+ import { collectContext } from './collectors/collectContext';
18
+ import { submitBugReport } from './services/bugReportService';
19
+ import { BugReportModal } from './components/BugReportModal';
20
+ import { ScreenshotEditor } from './components/ScreenshotEditor';
21
+ import { resolveTheme } from './theme';
22
+ import { logger } from './utils/logger';
23
+
24
+ interface Props {
25
+ config: BugReporterConfig;
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ /**
30
+ * Top-level SDK provider. Wrap your app in it once:
31
+ *
32
+ * <BugReporterProvider config={{ apiBaseUrl: '...' }}>
33
+ * <App />
34
+ * </BugReporterProvider>
35
+ *
36
+ * It listens for screenshots, collects context, and hosts the report modal.
37
+ */
38
+ export function BugReporterProvider({ config, children }: Props) {
39
+ const [isOpen, setIsOpen] = useState(false);
40
+ const [screenshot, setScreenshot] = useState<ScreenshotData | null>(null);
41
+ const [context, setContext] = useState<CollectedContext | null>(null);
42
+ const [submitState, setSubmitState] = useState<SubmitState>('idle');
43
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
44
+ const [editing, setEditing] = useState(false);
45
+
46
+ const theme = useMemo(() => resolveTheme(config.theme), [config.theme]);
47
+ const autoDetect = !config.disableAutoDetect;
48
+
49
+ useEffect(() => {
50
+ logger.setEnabled(!!config.debug);
51
+ }, [config.debug]);
52
+
53
+ // Keep a ref so the detector callback always sees the latest open state
54
+ // without re-subscribing on every render.
55
+ const isOpenRef = useRef(isOpen);
56
+ isOpenRef.current = isOpen;
57
+
58
+ const beginReport = useCallback(
59
+ async (shot: ScreenshotData | null) => {
60
+ setScreenshot(shot);
61
+ setSubmitState('idle');
62
+ setErrorMessage(null);
63
+ setContext(null);
64
+ setIsOpen(true);
65
+ try {
66
+ const collected = await collectContext(config);
67
+ setContext(collected);
68
+ } catch (e) {
69
+ logger.warn('context collection failed', e);
70
+ }
71
+ },
72
+ [config],
73
+ );
74
+
75
+ const handleScreenshot = useCallback(
76
+ (shot: ScreenshotData) => {
77
+ // Ignore a new screenshot while a report is already open.
78
+ if (isOpenRef.current) return;
79
+ logger.log('screenshot detected', shot.uri);
80
+ beginReport(shot.uri ? shot : null);
81
+ },
82
+ [beginReport],
83
+ );
84
+
85
+ useScreenshotDetector(autoDetect, handleScreenshot);
86
+
87
+ const openReporter = useCallback(
88
+ (shot?: ScreenshotData | null) => {
89
+ beginReport(shot ?? null);
90
+ },
91
+ [beginReport],
92
+ );
93
+
94
+ const closeReporter = useCallback(() => {
95
+ if (submitState === 'uploading' || submitState === 'saving') return;
96
+ setIsOpen(false);
97
+ setScreenshot(null);
98
+ setContext(null);
99
+ setSubmitState('idle');
100
+ setErrorMessage(null);
101
+ }, [submitState]);
102
+
103
+ const handleSubmit = useCallback(
104
+ async (input: BugReportInput) => {
105
+ // Context is normally ready; collect on demand if it isn't.
106
+ let ctx = context;
107
+ if (!ctx) {
108
+ setSubmitState('collecting');
109
+ ctx = await collectContext(config);
110
+ setContext(ctx);
111
+ }
112
+
113
+ try {
114
+ await submitBugReport({
115
+ input,
116
+ context: ctx,
117
+ screenshot,
118
+ config,
119
+ onState: setSubmitState,
120
+ });
121
+ // Briefly show success, then close.
122
+ setTimeout(() => {
123
+ setIsOpen(false);
124
+ setScreenshot(null);
125
+ setContext(null);
126
+ setSubmitState('idle');
127
+ }, 600);
128
+ } catch (e) {
129
+ setErrorMessage(
130
+ e instanceof Error
131
+ ? e.message
132
+ : 'Failed to submit. Please try again.',
133
+ );
134
+ }
135
+ },
136
+ [config, context, screenshot],
137
+ );
138
+
139
+ const value = useMemo(
140
+ () => ({
141
+ isAutoDetectEnabled: autoDetect,
142
+ openReporter,
143
+ closeReporter,
144
+ isOpen,
145
+ }),
146
+ [autoDetect, openReporter, closeReporter, isOpen],
147
+ );
148
+
149
+ return (
150
+ <BugReporterContext.Provider value={value}>
151
+ {children}
152
+ <BugReportModal
153
+ visible={isOpen}
154
+ screenshot={screenshot}
155
+ context={context}
156
+ showContext={config.showContextSummary !== false}
157
+ submitState={submitState}
158
+ errorMessage={errorMessage}
159
+ theme={theme}
160
+ onRemoveScreenshot={() => setScreenshot(null)}
161
+ onRequestEdit={() => setEditing(true)}
162
+ onSubmit={handleSubmit}
163
+ onClose={closeReporter}
164
+ />
165
+ {isOpen && editing && screenshot?.uri ? (
166
+ <ScreenshotEditor
167
+ screenshot={screenshot}
168
+ theme={theme}
169
+ onCancel={() => setEditing(false)}
170
+ onSave={uri => {
171
+ setScreenshot(prev => ({ ...(prev ?? {}), uri }));
172
+ setEditing(false);
173
+ }}
174
+ />
175
+ ) : null}
176
+ </BugReporterContext.Provider>
177
+ );
178
+ }
@@ -0,0 +1,15 @@
1
+ import DeviceInfoModule from 'react-native-device-info';
2
+ import type { AppInfo } from '../types';
3
+
4
+ /**
5
+ * Collects host-application metadata (name, bundle id, version, build).
6
+ */
7
+ export function collectAppInfo(environment?: string): AppInfo {
8
+ return {
9
+ appName: DeviceInfoModule.getApplicationName(),
10
+ bundleId: DeviceInfoModule.getBundleId(),
11
+ version: DeviceInfoModule.getVersion(),
12
+ buildNumber: DeviceInfoModule.getBuildNumber(),
13
+ environment,
14
+ };
15
+ }
@@ -0,0 +1,47 @@
1
+ import type { BugReporterConfig, CollectedContext, UserInfo } from '../types';
2
+ import { collectDeviceInfo } from './deviceInfo';
3
+ import { collectAppInfo } from './appInfo';
4
+ import { collectNetworkInfo } from './networkInfo';
5
+ import { logger } from '../utils/logger';
6
+
7
+ const EMPTY_USER: UserInfo = {};
8
+
9
+ async function resolveUser(config: BugReporterConfig): Promise<UserInfo> {
10
+ if (!config.getUser) {
11
+ return EMPTY_USER;
12
+ }
13
+ try {
14
+ return (await config.getUser()) ?? EMPTY_USER;
15
+ } catch (e) {
16
+ logger.warn('getUser() failed', e);
17
+ return EMPTY_USER;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Gathers every piece of automatic context for a bug report in parallel:
23
+ * user, device, app, network, current screen, and timestamp.
24
+ */
25
+ export async function collectContext(config: BugReporterConfig): Promise<CollectedContext> {
26
+ const [user, device, network] = await Promise.all([
27
+ resolveUser(config),
28
+ collectDeviceInfo(),
29
+ collectNetworkInfo(),
30
+ ]);
31
+
32
+ let currentScreen: string | null = null;
33
+ try {
34
+ currentScreen = config.getCurrentScreen?.() ?? null;
35
+ } catch (e) {
36
+ logger.warn('getCurrentScreen() failed', e);
37
+ }
38
+
39
+ return {
40
+ user,
41
+ device,
42
+ app: collectAppInfo(config.environment),
43
+ network,
44
+ currentScreen,
45
+ timestamp: new Date().toISOString(),
46
+ };
47
+ }
@@ -0,0 +1,51 @@
1
+ import DeviceInfoModule from 'react-native-device-info';
2
+ import { Platform } from 'react-native';
3
+ import type { DeviceInfo } from '../types';
4
+ import { logger } from '../utils/logger';
5
+
6
+ /**
7
+ * Collects device metadata. Every field is wrapped defensively because the
8
+ * various getters can reject on some devices/emulators.
9
+ */
10
+ export async function collectDeviceInfo(): Promise<DeviceInfo> {
11
+ const safe = async <T>(fn: () => Promise<T> | T, fallback: T): Promise<T> => {
12
+ try {
13
+ return await fn();
14
+ } catch (e) {
15
+ logger.warn('device info field failed', e);
16
+ return fallback;
17
+ }
18
+ };
19
+
20
+ const [
21
+ uniqueId,
22
+ totalMemory,
23
+ usedMemory,
24
+ batteryLevel,
25
+ freeDisk,
26
+ isEmulator,
27
+ ] = await Promise.all([
28
+ safe(() => DeviceInfoModule.getUniqueId(), 'unknown'),
29
+ safe(() => DeviceInfoModule.getTotalMemory(), 0),
30
+ safe(() => DeviceInfoModule.getUsedMemory(), 0),
31
+ safe(() => DeviceInfoModule.getBatteryLevel(), -1),
32
+ safe(() => DeviceInfoModule.getFreeDiskStorage(), 0),
33
+ safe(() => DeviceInfoModule.isEmulator(), false),
34
+ ]);
35
+
36
+ return {
37
+ brand: DeviceInfoModule.getBrand(),
38
+ manufacturer: await safe(() => DeviceInfoModule.getManufacturer(), 'unknown'),
39
+ model: DeviceInfoModule.getModel(),
40
+ deviceId: DeviceInfoModule.getDeviceId(),
41
+ systemName: Platform.OS,
42
+ systemVersion: DeviceInfoModule.getSystemVersion(),
43
+ isTablet: DeviceInfoModule.isTablet(),
44
+ isEmulator,
45
+ uniqueId,
46
+ totalMemory,
47
+ usedMemory,
48
+ batteryLevel,
49
+ freeDisk,
50
+ };
51
+ }
@@ -0,0 +1,31 @@
1
+ import NetInfo from '@react-native-community/netinfo';
2
+ import type { NetworkInfo } from '../types';
3
+ import { logger } from '../utils/logger';
4
+
5
+ /**
6
+ * Snapshots current network connectivity.
7
+ */
8
+ export async function collectNetworkInfo(): Promise<NetworkInfo> {
9
+ try {
10
+ const state = await NetInfo.fetch();
11
+ const cellularGeneration =
12
+ state.type === 'cellular' && state.details
13
+ ? (state.details as { cellularGeneration?: string | null }).cellularGeneration ?? null
14
+ : null;
15
+
16
+ return {
17
+ isConnected: state.isConnected,
18
+ isInternetReachable: state.isInternetReachable,
19
+ type: state.type,
20
+ cellularGeneration,
21
+ };
22
+ } catch (e) {
23
+ logger.warn('network info failed', e);
24
+ return {
25
+ isConnected: null,
26
+ isInternetReachable: null,
27
+ type: 'unknown',
28
+ cellularGeneration: null,
29
+ };
30
+ }
31
+ }
@@ -0,0 +1,160 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ Image,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from 'react-native';
11
+ import {
12
+ subscribeToReports,
13
+ updateReportStatus,
14
+ targetFromConfig,
15
+ } from '../services/supabaseService';
16
+ import type { BugReport, BugReporterConfig } from '../types';
17
+ import { SEVERITY_COLORS, resolveTheme } from '../theme';
18
+
19
+ interface Props {
20
+ config: Pick<
21
+ BugReporterConfig,
22
+ 'supabaseUrl' | 'supabaseAnonKey' | 'tableName' | 'storageBucket' | 'theme'
23
+ >;
24
+ }
25
+
26
+ const STATUS_FLOW: BugReport['status'][] = ['open', 'in_progress', 'resolved', 'closed'];
27
+
28
+ /**
29
+ * In-app admin viewer. Live-subscribes to the Supabase reports table with
30
+ * screenshot thumbnails and a tap-to-cycle status control.
31
+ */
32
+ export function BugReportAdminScreen({ config }: Props) {
33
+ const theme = resolveTheme(config.theme);
34
+ const target = targetFromConfig(config);
35
+ const targetKey = `${target.supabaseUrl}:${target.tableName}`;
36
+ const [reports, setReports] = useState<BugReport[]>([]);
37
+ const [loading, setLoading] = useState(true);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ useEffect(() => {
41
+ const unsub = subscribeToReports(
42
+ target,
43
+ data => {
44
+ setReports(data);
45
+ setLoading(false);
46
+ },
47
+ err => {
48
+ setError(err.message);
49
+ setLoading(false);
50
+ },
51
+ );
52
+ return unsub;
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ }, [targetKey]);
55
+
56
+ const cycleStatus = useCallback(
57
+ async (report: BugReport) => {
58
+ const next =
59
+ STATUS_FLOW[(STATUS_FLOW.indexOf(report.status) + 1) % STATUS_FLOW.length];
60
+ try {
61
+ await updateReportStatus(target, report.id!, next);
62
+ } catch {
63
+ // Live subscription re-syncs on the next change.
64
+ }
65
+ },
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ [targetKey],
68
+ );
69
+
70
+ if (loading) {
71
+ return (
72
+ <View style={[styles.center, { backgroundColor: theme.backgroundColor }]}>
73
+ <ActivityIndicator color={theme.primaryColor} />
74
+ </View>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <FlatList
80
+ style={{ backgroundColor: theme.backgroundColor }}
81
+ contentContainerStyle={styles.list}
82
+ data={reports}
83
+ keyExtractor={item => item.id || item.createdAt}
84
+ ListHeaderComponent={
85
+ <Text style={[styles.title, { color: theme.textColor }]}>
86
+ Bug Reports ({reports.length})
87
+ </Text>
88
+ }
89
+ ListEmptyComponent={
90
+ <Text style={[styles.empty, { color: theme.mutedColor }]}>
91
+ {error ? error : 'No bug reports yet.'}
92
+ </Text>
93
+ }
94
+ renderItem={({ item }) => (
95
+ <View style={[styles.card, { borderColor: theme.borderColor }]}>
96
+ {item.screenshotUrl ? (
97
+ <Image source={{ uri: item.screenshotUrl }} style={styles.shot} resizeMode="cover" />
98
+ ) : (
99
+ <View style={[styles.shot, styles.noShot, { borderColor: theme.borderColor }]}>
100
+ <Text style={{ color: theme.mutedColor, fontSize: 10 }}>No image</Text>
101
+ </View>
102
+ )}
103
+ <View style={styles.cardBody}>
104
+ <View style={styles.cardHeader}>
105
+ <View
106
+ style={[
107
+ styles.sevPill,
108
+ { backgroundColor: SEVERITY_COLORS[item.severity] || theme.mutedColor },
109
+ ]}>
110
+ <Text style={styles.sevText}>{item.severity}</Text>
111
+ </View>
112
+ <TouchableOpacity onPress={() => cycleStatus(item)} style={styles.statusBtn}>
113
+ <Text style={[styles.statusText, { color: theme.primaryColor }]}>
114
+ {item.status}
115
+ </Text>
116
+ </TouchableOpacity>
117
+ </View>
118
+ <Text style={[styles.cardTitle, { color: theme.textColor }]} numberOfLines={1}>
119
+ {item.title}
120
+ </Text>
121
+ <Text style={[styles.cardDesc, { color: theme.mutedColor }]} numberOfLines={2}>
122
+ {item.description}
123
+ </Text>
124
+ <Text style={[styles.cardMeta, { color: theme.mutedColor }]} numberOfLines={1}>
125
+ {item.context?.currentScreen || '—'} ·{' '}
126
+ {item.context?.device?.brand} {item.context?.device?.model} ·{' '}
127
+ {item.context?.user?.name || 'Anonymous'}
128
+ </Text>
129
+ </View>
130
+ </View>
131
+ )}
132
+ />
133
+ );
134
+ }
135
+
136
+ const styles = StyleSheet.create({
137
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
138
+ list: { padding: 16 },
139
+ title: { fontSize: 22, fontWeight: '700', marginBottom: 16 },
140
+ empty: { textAlign: 'center', marginTop: 60 },
141
+ card: {
142
+ flexDirection: 'row',
143
+ borderWidth: 1,
144
+ borderRadius: 12,
145
+ padding: 10,
146
+ marginBottom: 12,
147
+ gap: 12,
148
+ },
149
+ shot: { width: 70, height: 120, borderRadius: 8, backgroundColor: '#0b0c10' },
150
+ noShot: { borderWidth: 1, borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center' },
151
+ cardBody: { flex: 1 },
152
+ cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
153
+ sevPill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999 },
154
+ sevText: { color: '#fff', fontSize: 10, fontWeight: '700', textTransform: 'uppercase' },
155
+ statusBtn: { paddingVertical: 2, paddingHorizontal: 4 },
156
+ statusText: { fontSize: 12, fontWeight: '600', textTransform: 'capitalize' },
157
+ cardTitle: { fontSize: 15, fontWeight: '600', marginTop: 6 },
158
+ cardDesc: { fontSize: 13, marginTop: 2 },
159
+ cardMeta: { fontSize: 11, marginTop: 6 },
160
+ });