forked from ZyntariHQ/Invoisio
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscan.tsx
More file actions
377 lines (352 loc) · 10.8 KB
/
Copy pathscan.tsx
File metadata and controls
377 lines (352 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
import { CameraView, useCameraPermissions } from "expo-camera";
import { useRouter } from "expo-router";
import { useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Modal,
Pressable,
Share,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import {
parseQrCode,
type ParseQrErrorCode,
type ParsedPayment,
} from "../lib/parse-qr";
const QR_ERROR_TITLES: Record<ParseQrErrorCode, string> = {
"unsupported-format": "Unsupported QR code",
"missing-destination": "Incomplete QR code",
"invalid-destination": "Invalid destination",
};
export default function ScanScreen() {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [payment, setPayment] = useState<ParsedPayment | null>(null);
const [launching, setLaunching] = useState(false);
const processingRef = useRef(false);
// ── permission states ─────────────────────────────────────────────────────
if (!permission) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-[#050914]">
<ActivityIndicator color="#E2E8F0" size="large" />
</SafeAreaView>
);
}
if (!permission.granted) {
const blocked = !permission.canAskAgain;
return (
<SafeAreaView className="flex-1 items-center justify-center bg-[#050914] px-8">
<Text
className="text-center text-2xl text-white"
style={{ fontFamily: "SpaceGrotesk_700Bold" }}
>
Camera access needed
</Text>
<Text
className="mt-3 text-center text-base text-slate-400"
style={{ fontFamily: "SpaceGrotesk_400Regular" }}
>
{blocked
? "Camera access is blocked. Open your device settings to allow Invoisio to use the camera for scanning Stellar payment QR codes."
: "Allow camera access to scan Stellar payment QR codes."}
</Text>
{blocked ? (
<Pressable
className="mt-6 rounded-2xl bg-[#2663FF] px-8 py-4"
onPress={() => void Linking.openSettings()}
>
<Text
className="text-white"
style={{ fontFamily: "SpaceGrotesk_600SemiBold" }}
>
Open Settings
</Text>
</Pressable>
) : (
<Pressable
className="mt-6 rounded-2xl bg-[#2663FF] px-8 py-4"
onPress={() => void requestPermission()}
>
<Text
className="text-white"
style={{ fontFamily: "SpaceGrotesk_600SemiBold" }}
>
Grant permission
</Text>
</Pressable>
)}
<Pressable
className="mt-4 px-4 py-3"
onPress={() => {
router.back();
}}
>
<Text
className="text-slate-400"
style={{ fontFamily: "SpaceGrotesk_500Medium" }}
>
Go back
</Text>
</Pressable>
</SafeAreaView>
);
}
// ── handlers ──────────────────────────────────────────────────────────────
const handleBarcode = ({ data }: { data: string }) => {
if (processingRef.current || scanned) return;
processingRef.current = true;
const result = parseQrCode(data);
if ("code" in result) {
// cancelable:false guarantees the user picks an action, so the scanner
// can never be left silently disabled by an outside-tap dismissal.
Alert.alert(
QR_ERROR_TITLES[result.code],
result.message,
[
{
text: "Try again",
onPress: () => {
processingRef.current = false;
},
},
{
text: "Stop scanning",
style: "cancel",
onPress: () => {
processingRef.current = false;
router.back();
},
},
],
{ cancelable: false },
);
return;
}
setPayment(result);
setScanned(true);
};
const sharePaymentLink = async (payment: ParsedPayment) => {
try {
await Share.share({
message: payment.sep0007Uri,
title: "Stellar payment",
});
} catch {
Alert.alert(
"Unable to share",
"The payment link could not be shared. Please try again.",
[{ text: "OK" }],
);
}
};
const openWallet = async (payment: ParsedPayment) => {
const canOpen = await Linking.canOpenURL(payment.sep0007Uri);
if (!canOpen) {
Alert.alert(
"No wallet found",
"No Stellar wallet app (e.g. LOBSTR) is installed that can handle this payment link. Share the link to open it in a wallet or browser, or install a Stellar wallet and try again.",
[
{
text: "Share link",
onPress: () => void sharePaymentLink(payment),
},
{
text: "Try again",
onPress: () => void openWallet(payment),
},
{ text: "Cancel", style: "cancel" },
],
);
return;
}
await Linking.openURL(payment.sep0007Uri);
};
const handleConfirmPayment = async () => {
if (!payment) return;
setLaunching(true);
try {
await openWallet(payment);
} catch {
Alert.alert(
"Couldn't open wallet",
"The wallet app could not be opened. Try again, or share the payment link to open it elsewhere.",
[
{
text: "Try again",
onPress: () => void handleConfirmPayment(),
},
{
text: "Share link",
onPress: () => void sharePaymentLink(payment),
},
{ text: "Cancel", style: "cancel" },
],
);
} finally {
setLaunching(false);
}
};
const handleDismiss = () => {
setPayment(null);
setScanned(false);
processingRef.current = false;
};
// ── render ────────────────────────────────────────────────────────────────
return (
<SafeAreaView className="flex-1 bg-[#050914]">
{/* Header */}
<View className="flex-row items-center px-6 py-4">
<Pressable
className="mr-4 rounded-xl border border-white/20 px-4 py-2"
onPress={() => {
router.back();
}}
>
<Text
className="text-white"
style={{ fontFamily: "SpaceGrotesk_500Medium" }}
>
← Back
</Text>
</Pressable>
<Text
className="text-lg text-white"
style={{ fontFamily: "SpaceGrotesk_600SemiBold" }}
>
Scan to Pay
</Text>
</View>
{/* Camera */}
<View className="flex-1 overflow-hidden rounded-3xl mx-4 mb-4">
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={scanned ? undefined : handleBarcode}
/>
{/* Finder overlay */}
<View
pointerEvents="none"
className="absolute inset-0 items-center justify-center"
>
<View className="h-64 w-64 rounded-3xl border-2 border-[#2663FF]" />
<Text
className="mt-6 text-center text-sm text-white/70"
style={{ fontFamily: "SpaceGrotesk_400Regular" }}
>
Align the QR code inside the frame
</Text>
</View>
</View>
{/* Confirmation modal */}
<Modal
visible={!!payment}
transparent
animationType="slide"
onRequestClose={handleDismiss}
>
<View className="flex-1 justify-end bg-black/60">
<View className="rounded-t-3xl bg-[#0D1526] px-6 pt-6 pb-10">
<Text
className="text-xl text-white"
style={{ fontFamily: "SpaceGrotesk_700Bold" }}
>
Confirm Payment
</Text>
<Text
className="mt-1 text-sm text-slate-400"
style={{ fontFamily: "SpaceGrotesk_400Regular" }}
>
Review the details before opening your wallet.
</Text>
{payment && (
<View className="mt-5 gap-4 rounded-2xl border border-white/10 bg-white/5 p-4">
<ConfirmRow
label="Destination"
value={payment.destination}
mono
/>
{payment.amount && (
<ConfirmRow
label="Amount"
value={`${payment.amount} ${payment.assetCode ?? "XLM"}`}
/>
)}
{payment.memo && (
<ConfirmRow
label={`Memo (${payment.memoType})`}
value={payment.memo}
mono
/>
)}
</View>
)}
<Pressable
className="mt-5 rounded-2xl bg-[#2663FF] py-4 items-center"
disabled={launching}
onPress={() => void handleConfirmPayment()}
>
{launching ? (
<ActivityIndicator color="#fff" />
) : (
<Text
className="text-white text-base"
style={{ fontFamily: "SpaceGrotesk_600SemiBold" }}
>
Open Wallet to Pay
</Text>
)}
</Pressable>
<Pressable
className="mt-3 rounded-2xl border border-white/20 py-4 items-center"
onPress={handleDismiss}
>
<Text
className="text-slate-300 text-base"
style={{ fontFamily: "SpaceGrotesk_500Medium" }}
>
Cancel
</Text>
</Pressable>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
function ConfirmRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<View className="gap-1">
<Text
className="text-xs uppercase tracking-[0.22em] text-slate-400"
style={{ fontFamily: "SpaceGrotesk_500Medium" }}
>
{label}
</Text>
<Text
className="text-sm text-white"
numberOfLines={mono ? 2 : undefined}
style={{
fontFamily: mono
? "SpaceGrotesk_500Medium"
: "SpaceGrotesk_400Regular",
}}
>
{value}
</Text>
</View>
);
}