Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dapps/pos-app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
"iosPermissions": ["Bluetooth"]
}
],
[
"expo-camera",
{
"cameraPermission": "WalletConnect Pay uses the camera to scan a Customer API key QR code."
}
],
[
"expo-secure-store",
{
Expand Down
7 changes: 7 additions & 0 deletions dapps/pos-app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ export default Sentry.wrap(function RootLayout() {
name="settings"
options={{ headerTitle: SettingsHeaderTitle }}
/>
<Stack.Screen
name="scan-api-key"
options={{
headerShown: false,
contentStyle: { backgroundColor: "black", paddingBottom: 0 },
}}
/>
<Stack.Screen
name="activity"
options={{ headerTitle: TransactionsHeaderTitle }}
Expand Down
188 changes: 188 additions & 0 deletions dapps/pos-app/app/scan-api-key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Button } from "@/components/button";
import { Pressable } from "@/components/pressable";
import { ScanCorners } from "@/components/scan-corners";
import { ThemedText } from "@/components/themed-text";
import { BorderRadius, Spacing } from "@/constants/spacing";
import { usePendingApiKeyScanStore } from "@/store/usePendingApiKeyScanStore";
import {
BarcodeScanningResult,
CameraView,
useCameraPermissions,
} from "expo-camera";
import { useAssets } from "expo-asset";
import { Image } from "expo-image";
import { router, useIsFocused } from "expo-router";
import { useEffect, useRef } from "react";
import { Linking, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

const SCAN_AREA_SIZE = 260;

export default function ScanApiKeyScreen() {
const insets = useSafeAreaInsets();
const isFocused = useIsFocused();
const [permission, requestPermission] = useCameraPermissions();
const setScannedValue = usePendingApiKeyScanStore(
(state) => state.setScannedValue,
);
const [assets] = useAssets([require("@/assets/images/close.png")]);

// Guards against the scanner firing multiple times before the screen pops.
const handledRef = useRef(false);

// Ask for camera access as soon as the screen mounts.
useEffect(() => {
if (permission && !permission.granted && permission.canAskAgain) {
requestPermission();
}
}, [permission, requestPermission]);

const handleBarcodeScanned = (result: BarcodeScanningResult) => {
if (handledRef.current) return;
const value = result.data?.trim();
if (!value) return;
handledRef.current = true;
setScannedValue(value);
router.back();
};

const close = () => router.back();

const isDenied = permission?.granted === false && !permission.canAskAgain;

return (
<View style={styles.container}>
{isFocused && permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={handleBarcodeScanned}
/>
) : null}

{/* Dimmed mask with a transparent square window (four strips around it). */}
<View style={styles.overlay} pointerEvents="none">
<View style={styles.mask} />
<View style={styles.middleRow}>
<View style={styles.mask} />
<View style={styles.window}>
<ScanCorners
size={SCAN_AREA_SIZE}
color="#FFFFFF"
length={44}
thickness={4}
radius={BorderRadius["3"]}
/>
</View>
<View style={styles.mask} />
</View>
<View style={[styles.mask, styles.bottomMask]}>
<ThemedText
fontSize={16}
lineHeight={22}
color="text-white"
style={styles.instruction}
>
{isDenied
? "Camera access is off. Enable it in your device settings to scan."
: "Point your camera at the API key QR code"}
</ThemedText>
</View>
</View>

{/* Close button, top-left, above the safe-area inset. */}
<Pressable
onPress={close}
accessibilityLabel="Close scanner"
style={[
styles.closeButton,
{
top: insets.top + Spacing["spacing-2"],
borderColor: "rgba(255, 255, 255, 0.4)",
},
]}
>
<Image
source={assets?.[0]}
style={styles.closeIcon}
tintColor="#FFFFFF"
cachePolicy="memory-disk"
/>
</Pressable>

{isDenied ? (
<View
style={[
styles.deniedActions,
{ bottom: insets.bottom + Spacing["spacing-6"] },
]}
>
<Button
type="accent"
variant="primary"
onPress={() => Linking.openSettings()}
>
Open settings
</Button>
</View>
) : null}
</View>
);
}

const MASK_COLOR = "rgba(0, 0, 0, 0.7)";

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "black",
},
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
mask: {
flex: 1,
backgroundColor: MASK_COLOR,
},
middleRow: {
flexDirection: "row",
height: SCAN_AREA_SIZE,
},
window: {
width: SCAN_AREA_SIZE,
height: SCAN_AREA_SIZE,
},
bottomMask: {
alignItems: "center",
paddingTop: Spacing["spacing-7"],
paddingHorizontal: Spacing["spacing-8"],
},
instruction: {
textAlign: "center",
},
closeButton: {
position: "absolute",
left: Spacing["spacing-5"],
width: 38,
height: 38,
borderRadius: BorderRadius["3"],
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.3)",
},
closeIcon: {
width: 20,
height: 20,
},
deniedActions: {
position: "absolute",
left: Spacing["spacing-5"],
right: Spacing["spacing-5"],
},
});
115 changes: 91 additions & 24 deletions dapps/pos-app/app/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { PinModal } from "@/components/pin-modal";
import { Pressable } from "@/components/pressable";
import { RadioList, RadioOption } from "@/components/radio-list";
import { SettingsBottomSheet } from "@/components/settings-bottom-sheet";
import { SettingsItem } from "@/components/settings-item";
Expand All @@ -10,10 +11,12 @@ import { SetupBanner } from "@/components/setup-banner";
import { ThemedText } from "@/components/themed-text";
import { BorderRadius, Spacing } from "@/constants/spacing";
import { useBiometricAuth } from "@/hooks/use-biometric-auth";
import { useHasCamera } from "@/hooks/use-has-camera";
import { useMerchantFlow } from "@/hooks/use-merchant-flow";
import { useNfcCapabilities } from "@/hooks/use-nfc-capabilities";
import { useTheme } from "@/hooks/use-theme-color";
import { useLogsStore } from "@/store/useLogsStore";
import { usePendingApiKeyScanStore } from "@/store/usePendingApiKeyScanStore";
import { useSettingsStore } from "@/store/useSettingsStore";
import { usePosBridgeStore } from "@/store/usePosBridgeStore";
import { isRunningInIframe } from "@/utils/is-running-in-iframe";
Expand All @@ -33,7 +36,7 @@ import * as Application from "expo-application";
import Constants from "expo-constants";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Platform, StyleSheet, TextInput, View } from "react-native";
import { ScrollView } from "react-native-gesture-handler";

Expand Down Expand Up @@ -91,6 +94,7 @@ export default function SettingsScreen() {
const isIframeBridgeConfigured = isIframeSession && isBridgeConfigured;

const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const hasCamera = useHasCamera();

// Custom hooks for biometrics and merchant flow
const {
Expand Down Expand Up @@ -118,6 +122,7 @@ export default function SettingsScreen() {
resetCustomerApiKeyInput,
handleMerchantIdConfirm,
handleCustomerApiKeyConfirm,
handleScannedCustomerApiKey,
handlePinVerifyComplete,
handleBiometricPress,
handlePinSetupComplete,
Expand All @@ -128,6 +133,19 @@ export default function SettingsScreen() {
biometricLabel,
});

// Pick up an API key scanned by the full-screen scan route once it pops back,
// then run the auto-save flow (PIN/biometric) here at the settings root.
const scannedApiKey = usePendingApiKeyScanStore(
(state) => state.scannedValue,
);
const clearScannedApiKey = usePendingApiKeyScanStore((state) => state.clear);

useEffect(() => {
if (!scannedApiKey) return;
handleScannedCustomerApiKey(scannedApiKey);
clearScannedApiKey();
}, [scannedApiKey, handleScannedCustomerApiKey, clearScannedApiKey]);

const currencyOptions: RadioOption<CurrencyCode>[] = useMemo(
() =>
CURRENCIES.map((c) => ({
Expand Down Expand Up @@ -174,6 +192,11 @@ export default function SettingsScreen() {
handleCustomerApiKeyConfirm();
};

const handleScanApiKeyPress = () => {
closeSheet();
router.push("/scan-api-key");
};

const handleTestModeChange = (enabled: boolean) => {
setTestMode(enabled);
if (enabled) {
Expand Down Expand Up @@ -488,29 +511,53 @@ export default function SettingsScreen() {
onClose={closeSheet}
>
<View style={styles.inputContent}>
<TextInput
value={
isEditingCustomerApiKey
? customerApiKeyInput
: hasStoredCustomerApiKey
? "********"
: ""
}
onChangeText={handleCustomerApiKeyInputChange}
placeholder="Enter customer API key"
placeholderTextColor={theme["text-tertiary"]}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={true}
style={[
styles.sheetInput,
{
borderColor: theme["border-primary"],
color: theme["text-primary"],
backgroundColor: theme["foreground-primary"],
},
]}
/>
<View style={styles.inputRow}>
<TextInput
value={
isEditingCustomerApiKey
? customerApiKeyInput
: hasStoredCustomerApiKey
? "********"
: ""
}
onChangeText={handleCustomerApiKeyInputChange}
placeholder="Enter customer API key"
placeholderTextColor={theme["text-tertiary"]}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={true}
style={[
styles.sheetInput,
styles.inputWithAction,
{
borderColor: theme["border-primary"],
color: theme["text-primary"],
backgroundColor: theme["foreground-primary"],
},
]}
/>
{hasCamera && (
<Pressable
onPress={handleScanApiKeyPress}
testID="settings-customer-scan"
accessibilityLabel="Scan API key QR code"
style={[
styles.scanButton,
{
borderColor: theme["border-primary"],
backgroundColor: theme["foreground-primary"],
},
]}
>
<Image
source={require("@/assets/images/scan.png")}
style={styles.scanIcon}
tintColor={theme["text-primary"]}
cachePolicy="memory-disk"
/>
</Pressable>
)}
</View>
<Button
type="accent"
variant="primary"
Expand Down Expand Up @@ -569,6 +616,26 @@ const styles = StyleSheet.create({
inputContent: {
gap: Spacing["spacing-3"],
},
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: Spacing["spacing-3"],
},
inputWithAction: {
flex: 1,
},
scanButton: {
width: 60,
height: 60,
borderWidth: 1,
borderRadius: BorderRadius["4"],
alignItems: "center",
justifyContent: "center",
},
scanIcon: {
width: 24,
height: 24,
},
sheetInput: {
borderWidth: 1,
borderRadius: BorderRadius["4"],
Expand Down
Binary file added dapps/pos-app/assets/images/scan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading