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() {