From a4d4e03eb9c3dbddefc187c8569237d68433003f Mon Sep 17 00:00:00 2001 From: ppc Date: Sun, 12 Jan 2025 19:45:24 +0000 Subject: [PATCH] new "card add" flow --- amnet-native/App.tsx | 14 ++- amnet-native/app/CardAddModal.tsx | 124 --------------------- amnet-native/app/card/CardAddModal.tsx | 95 ++++++++++++++++ amnet-native/app/card/CardEditorModal.tsx | 97 ++++++++++++++++ amnet-native/app/card/ManualCardEntry.tsx | 108 ++++++++++++++++++ amnet-native/app/card/PhyscialCardScan.tsx | 90 +++++++++++++++ amnet-native/utils/ModalStyles.ts | 9 +- 7 files changed, 407 insertions(+), 130 deletions(-) delete mode 100644 amnet-native/app/CardAddModal.tsx create mode 100644 amnet-native/app/card/CardAddModal.tsx create mode 100644 amnet-native/app/card/CardEditorModal.tsx create mode 100644 amnet-native/app/card/ManualCardEntry.tsx create mode 100644 amnet-native/app/card/PhyscialCardScan.tsx diff --git a/amnet-native/App.tsx b/amnet-native/App.tsx index eb28b85..e3a77ea 100644 --- a/amnet-native/App.tsx +++ b/amnet-native/App.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from "react"; +import React from "react"; import {StatusBar} from "expo-status-bar"; import {useColorScheme, View} from "react-native"; import {Server, WalletCards} from "lucide-react-native"; @@ -7,8 +7,8 @@ import {SafeAreaProvider} from "react-native-safe-area-context"; import {MD3Colors} from "react-native-paper/lib/typescript/types"; import {createBottomTabNavigator} from "@react-navigation/bottom-tabs"; import {createNativeStackNavigator} from "@react-navigation/native-stack"; -import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native'; import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono"; +import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native'; import { ActivityIndicator, adaptNavigationTheme, @@ -19,12 +19,15 @@ import { Paragraph } from 'react-native-paper'; -import RealmContext from "./models/RealmContext"; import linking from "./Linking"; +import RealmContext from "./models/RealmContext"; import {AMUITabBar} from "./components/AMUITabBar"; -import CardAddModal from "./app/CardAddModal"; + +import CardEditorModal from "./app/card/CardEditorModal"; +import CardAddModal from "./app/card/CardAddModal"; import ServerAddModal from "./app/ServerAddModal"; + import CardManagerStack from "./app/CardManagerStack"; import ServerManagerStack from "./app/ServerManagerStack"; @@ -118,7 +121,8 @@ export default function AppRoot() { - + + diff --git a/amnet-native/app/CardAddModal.tsx b/amnet-native/app/CardAddModal.tsx deleted file mode 100644 index ca39332..0000000 --- a/amnet-native/app/CardAddModal.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import {generateCardNumber} from "amnet-shared"; -import {useModalStyles} from "../utils/ModalStyles"; -import {useCallback, useLayoutEffect, useState} from "react"; -import {Platform, TextInput, TouchableOpacity, View} from "react-native"; -import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper"; - -import {AMCard} from "../models/AMCard"; -import RealmContext from "../models/RealmContext"; - -const {useRealm} = RealmContext; - -export default function CardAddModal({navigation, route}) { - const realm = useRealm(); - const theme = useTheme(); - const modalStyles = useModalStyles(); - - // state values - const [cardName, setCardName] = useState(''); - const [cardNumber, setCardNumber] = useState(''); - const [cardNumberReadOnly, setCardNumberReadOnly] = useState(false); - - const cardAddCallback = useCallback((cardId: string, cardName: string) => { - const duplicateCard = realm.objects("card").find(x => x.code === cardId); - - if (duplicateCard) { - realm.write(() => { - duplicateCard.name = cardName; - }); - } else { - realm.write(() => { - realm.create("card", AMCard.generate(cardId, cardName)); - }); - } - - navigation.goBack(); - }, [realm]); - - // header effects - if (Platform.OS === 'ios') { - useLayoutEffect(() => { - navigation.setOptions({ - headerLeft: () => navigation.goBack()}> - Cancel - , - }); - }, [navigation, modalStyles]); - } - - useLayoutEffect(() => { - const canSave = cardName.length && cardNumber.length === 20; - const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline}; - - navigation.setOptions({ - headerRight: () => cardAddCallback(cardNumber, cardName)}> - Save - - }) - }, [navigation, modalStyles, cardName, cardNumber]); - - // enable renaming by passing cardId to modal - useLayoutEffect(() => { - if (!route.params?.cardId) { - setCardNumberReadOnly(false); - return; - } - - setCardNumber(route.params.cardId); - setCardNumberReadOnly(true); - - if (route.params.cardName) { - setCardName(route.params.cardName); - } - - navigation.setOptions({ - title: "Edit Card" - }); - }, [route]); - - return ( - - - - - Card Name - - - - - - - - - Matrix Code - - setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')} - style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : theme.colors.onSurface}}/> - - - - - {/*only show card entry info when in create mode*/} - {!cardNumberReadOnly && ( - <> - - If you have a physical card, you can enter the 20-digit code on the back. - - - - - )} - ) -} diff --git a/amnet-native/app/card/CardAddModal.tsx b/amnet-native/app/card/CardAddModal.tsx new file mode 100644 index 0000000..eed0cfa --- /dev/null +++ b/amnet-native/app/card/CardAddModal.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import {Platform, TouchableOpacity, View} from "react-native"; + +import {useNavigation} from "@react-navigation/native"; +import {createNativeStackNavigator} from "@react-navigation/native-stack"; + +import {Card, Paragraph, Text, Title, useTheme} from "react-native-paper"; +import {SmartphoneNfc, SquareAsterisk, SquarePlus} from "lucide-react-native"; + +import ManualCardEntry from "./ManualCardEntry"; +import PhysicalCardScan from "./PhyscialCardScan"; + +const Stack = createNativeStackNavigator(); + +function CardAddModal() { + return ( + + + + + + + ) +} + +function CardAddMethodSelector() { + const theme = useTheme(); + const navigation: any = useNavigation(); + + return ( + + + Add new Card + AMNet supports scanning physical cards, entering details + manually or generating new ones. + + + } + title="Scan Physical Card" + description="Amusement IC or Transport cards supported" + onPress={() => navigation.navigate('Scan')}/> + + + + } + title="Enter Details Manually" + //description="Manually enter card details (or generate a new one)" + onPress={() => navigation.navigate('Manual')}/> + + } + title="Generate New Card" + //description="Create a card from scratch" + onPress={() => navigation.navigate('Manual', {autoGenerate: true})}/> + + ) +} + +function MethodCard(props: { + title: string, + description?: string, + color: string, + icon: React.ReactElement, + onPress: () => void +}) { + const theme = useTheme(); + const large = props.description?.length > 0; + + return ( + props.onPress()}> + + + + {React.cloneElement(props.icon, {size: large ? 24 : 16, color: theme.colors.surfaceVariant})} + + + {props.title} + {props.description && ({props.description})} + + + + + ) +} + +export default Platform.OS === 'ios' ? CardAddModal : ManualCardEntry; diff --git a/amnet-native/app/card/CardEditorModal.tsx b/amnet-native/app/card/CardEditorModal.tsx new file mode 100644 index 0000000..19efa92 --- /dev/null +++ b/amnet-native/app/card/CardEditorModal.tsx @@ -0,0 +1,97 @@ +import {BSON} from "realm"; +import {Card, Divider, Paragraph, useTheme} from "react-native-paper"; +import {useCallback, useEffect, useLayoutEffect, useState} from "react"; +import {Platform, TextInput, TouchableOpacity, View} from "react-native"; + +import {useModalStyles} from "../../utils/ModalStyles"; + +import {AMCard} from "../../models/AMCard"; +import RealmContext from "../../models/RealmContext"; + +const {useObject, useRealm} = RealmContext; + +export default function CardEditorModal({navigation, route}) { + if (!route.params || !route.params.cardId) { + navigation.goBack(); + return; + } + + const theme = useTheme(); + const modalStyles = useModalStyles(); + + const realm = useRealm(); + const card = useObject(AMCard, BSON.ObjectId.createFromHexString(route.params.cardId)); + + const [cardName, setCardName] = useState(''); + + const updateCardCallback = useCallback((newName: string) => { + realm.write(() => { + card.name = newName; + }); + + navigation.goBack(); + }, [realm, navigation]); + + useEffect(() => { + if (card?.isValid() !== true) { + navigation.goBack(); + return; + } + + if (!cardName.length) { + setCardName(card.name); + } + }, [card]); + + // header effects + if (Platform.OS === 'ios') { + useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: () => navigation.goBack()}> + Cancel + , + }); + }, [navigation, modalStyles]); + } + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => updateCardCallback(cardName)} disabled={!cardName.length}> + Save + + }); + }, [cardName]); + + return ( + + + + + + Card Name + + + + + + + + + Access Code + + + + + + + ) +} \ No newline at end of file diff --git a/amnet-native/app/card/ManualCardEntry.tsx b/amnet-native/app/card/ManualCardEntry.tsx new file mode 100644 index 0000000..ffdf8cb --- /dev/null +++ b/amnet-native/app/card/ManualCardEntry.tsx @@ -0,0 +1,108 @@ +import {generateCardNumber} from "amnet-shared"; +import {useCallback, useEffect, useLayoutEffect, useState} from "react"; +import {Platform, TextInput, TouchableOpacity, View} from "react-native"; +import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper"; + +import {AMCard} from "../../models/AMCard"; +import RealmContext from "../../models/RealmContext"; +import {useModalStyles} from "../../utils/ModalStyles"; + +const {useRealm} = RealmContext; + +export default function CardAddModal({navigation, route}) { + const realm = useRealm(); + const theme = useTheme(); + const modalStyles = useModalStyles(); + + // state values + const [cardName, setCardName] = useState(''); + const [cardNumber, setCardNumber] = useState(''); + + const cardAddCallback = useCallback((cardId: string, cardName: string) => { + const duplicateCard = realm.objects(AMCard.schema.name).find(x => x.code === cardId); + + if (duplicateCard) { + realm.write(() => { + duplicateCard.name = cardName; + }); + } else { + realm.write(() => { + realm.create(AMCard.schema.name, AMCard.generate(cardId, cardName)); + }); + } + + if (Platform.OS === 'ios') { + navigation.getParent()?.goBack(); + } else { + navigation.goBack(); + } + }, [realm]); + + // header effects + if (Platform.OS === 'ios') { + useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: () => navigation.goBack()}> + Back + , + }); + }, [navigation, modalStyles]); + } + + useLayoutEffect(() => { + const canSave = cardName.length && cardNumber.length === 20; + const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline}; + + navigation.setOptions({ + headerRight: () => cardAddCallback(cardNumber, cardName)}> + Save + + }) + }, [navigation, modalStyles, cardName, cardNumber]); + + // check params for autoGenerate request + useEffect(() => { + if (route.params?.autoGenerate) { + setCardNumber(generateCardNumber()); + } + }, []); + + return ( + + + + + + Card Name + + + + + + + + + Access Code + + setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')} + style={{...modalStyles.formItemInput, color: theme.colors.onSurface}}/> + + + + + {/* only show card entry info when in create mode */} + + + ) +} diff --git a/amnet-native/app/card/PhyscialCardScan.tsx b/amnet-native/app/card/PhyscialCardScan.tsx new file mode 100644 index 0000000..19d235b --- /dev/null +++ b/amnet-native/app/card/PhyscialCardScan.tsx @@ -0,0 +1,90 @@ +import {useNavigation} from "@react-navigation/native"; +import {Card, Paragraph, useTheme} from "react-native-paper"; +import {useCallback, useLayoutEffect, useState} from "react"; +import {TextInput, TouchableOpacity, View} from "react-native"; + +import {useModalStyles} from "../../utils/ModalStyles"; + +import RealmContext from "../../models/RealmContext"; +import {AMCard, AMPhysicalCardData} from "../../models/AMCard"; +import {felicaCardReadAsync, icCard0008Number, ICType} from "../../lib/iccard"; + +const {useRealm} = RealmContext; + +export default function PhysicalCardScan() { + const realm = useRealm(); + const theme = useTheme(); + const navigation = useNavigation(); + const modalStyles = useModalStyles(); + + const [cardName, setCardName] = useState(''); + + const handleScan = useCallback(async () => { + const cardInfo = await felicaCardReadAsync(); + + if (!cardInfo) { + return; + } + + const accessCode = cardInfo.type === ICType.Amusement ? cardInfo.accessCode : icCard0008Number(cardInfo.idm); + + realm.write(() => { + let existingCard = realm.objects(AMCard.schema.name).find(x => x.code === accessCode); + const physicalCardInfo = realm.create(AMPhysicalCardData.schema.name, { + cardIDm: cardInfo.idm, + cardType: cardInfo.type, + cardVendor: cardInfo.type === ICType.Amusement ? cardInfo.vendor : null + }); + + if (!existingCard) { + existingCard = realm.create(AMCard.schema.name, AMCard.generate(accessCode, cardName)); + } else { + existingCard.name = cardName; + } + + // set physical card props + existingCard.added = new Date(); + existingCard.physicalCardData = physicalCardInfo; + }); + + // dismiss the parent screen (this screen can only be accessed from modal -> stack) + navigation.getParent()?.goBack(); + }, [realm, navigation, cardName]); + + useLayoutEffect(() => { + const canSave = cardName.length; + const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline}; + + navigation.setOptions({ + headerRight: () => handleScan()}> + Scan + + }) + }, [navigation, modalStyles, cardName]); + + return ( + + + + + + Card Name + + + + + + + + Ensure you have either an Amusement IC or Transport card ready to scan. + After the scan has completed, the card will be saved to your device. + + + ) +} \ No newline at end of file diff --git a/amnet-native/utils/ModalStyles.ts b/amnet-native/utils/ModalStyles.ts index 8b6ba2c..b3a0ac6 100644 --- a/amnet-native/utils/ModalStyles.ts +++ b/amnet-native/utils/ModalStyles.ts @@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({ }, formItemDivider: { marginVertical: 15 - } + }, + rootLayout: { + display: "flex", + gap: 10, + justifyContent: "center", + paddingHorizontal: 15, + paddingTop: 25 + }, }); export function useModalStyles() {