mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-09-22 22:28:22 +03:00
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
import {BSON} from "realm";
|
|
import NfcManager from "react-native-nfc-manager";
|
|
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
|
import {Alert, Platform, RefreshControl, ScrollView, Share, StyleSheet, Text, View} from "react-native";
|
|
import {ActivityIndicator, Button, IconButton, Paragraph, Title, useTheme} from "react-native-paper";
|
|
import {
|
|
CircleCheck,
|
|
CircleDot,
|
|
CircleEllipsis,
|
|
CreditCard,
|
|
Plus,
|
|
SmartphoneNfcIcon,
|
|
WifiOff,
|
|
TvMinimalPlay,
|
|
Star,
|
|
SmartphoneNfc,
|
|
SquareArrowOutUpRight
|
|
} from "lucide-react-native";
|
|
|
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
|
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
|
import {AMUICardDetail} from "../components/AMUICard";
|
|
import {ICCard} from "../components/ICCard";
|
|
|
|
import RealmContext from "../models/RealmContext";
|
|
import {AMServer} from "../models/AMServer";
|
|
import {AMCard} from "../models/AMCard";
|
|
|
|
import {felicaCardReadAsync, icCard0008Number, ICType} from "../lib/iccard";
|
|
import {
|
|
AMCardInRequest,
|
|
AMServerConnectionInfo,
|
|
AMServerInstanceMetrics,
|
|
fetchServerInfo,
|
|
getGameName,
|
|
performCardIn
|
|
} from "amnet-shared";
|
|
|
|
import ObjectId = BSON.ObjectId;
|
|
import {AMCardRecentUse} from "../models/AMCardRecentUse";
|
|
import {MenuAction, MenuView, NativeActionEvent} from "@react-native-menu/menu";
|
|
|
|
const {useRealm, useQuery, useObject} = RealmContext;
|
|
|
|
export default function ServerCardIn({route, navigation}) {
|
|
const realm = useRealm();
|
|
const theme = useTheme()
|
|
|
|
const {serverId} = route.params;
|
|
const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
|
|
const cards = useQuery({
|
|
type: AMCard,
|
|
query: q => q.sorted("lastUsed", true)
|
|
});
|
|
|
|
// header actions
|
|
const menuCallback = useCallback(async (e: NativeActionEvent) => {
|
|
switch (e.nativeEvent.event) {
|
|
case "share":
|
|
Share.share({url: server.shareUrl});
|
|
break;
|
|
|
|
case "delete":
|
|
Alert.alert("Delete Server", "Are you sure you want to delete this server?", [
|
|
{
|
|
text: "Cancel",
|
|
style: "cancel"
|
|
},
|
|
{
|
|
text: "Delete",
|
|
style: "destructive",
|
|
onPress: () => {
|
|
realm.write(() => {
|
|
realm.delete(server);
|
|
navigation.goBack();
|
|
});
|
|
}
|
|
}
|
|
]);
|
|
|
|
break;
|
|
}
|
|
}, [realm, server, navigation]);
|
|
|
|
// nfc support
|
|
const [nfcSupported, setNfcSupported] = useState<boolean | null>(Platform.select({android: false, default: null}));
|
|
useEffect(() => {
|
|
if (nfcSupported !== null) {
|
|
return;
|
|
}
|
|
|
|
NfcManager.isSupported().then(setNfcSupported);
|
|
}, []);
|
|
|
|
// server state
|
|
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
|
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
|
const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
|
|
|
|
const [userSelectedCard, setUserSelectedCard] = useState<AMCard>(null);
|
|
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null);
|
|
|
|
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
|
|
|
|
const serverDefaultCard = useMemo(() => {
|
|
if (server?.isValid() !== true || !server.gameId) {
|
|
return null;
|
|
}
|
|
|
|
return realm.objects<AMCardRecentUse>(AMCardRecentUse.schema.name).find(x => x.gameId === server.gameId)?.card;
|
|
}, [realm, cards, server?.gameId]);
|
|
|
|
const selectedCard = useMemo(() => [userSelectedCard, serverDefaultCard, cards.at(0)].find(x => x?.isValid() == true) ?? null, [userSelectedCard, serverDefaultCard, cards]);
|
|
const unselectedCards = useMemo(() => cards.filter(x => x.isValid() === true && x._id.toHexString() !== selectedCard?._id.toHexString()), [cards, selectedCard]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (server?.isValid() !== true) {
|
|
return;
|
|
}
|
|
|
|
navigation.setOptions({
|
|
title: server.serverName,
|
|
headerRight: () => (
|
|
<MenuView actions={menuActions} onPressAction={menuCallback}>
|
|
<CircleEllipsis color={theme.colors.primary}/>
|
|
</MenuView>
|
|
)
|
|
});
|
|
}, [navigation, server]);
|
|
|
|
useEffect(() => {
|
|
if (server?.isValid() !== true) {
|
|
navigation.goBack();
|
|
return;
|
|
}
|
|
|
|
if (!serverConnectionSuccess) {
|
|
return;
|
|
}
|
|
|
|
const metrics = server as AMServerInstanceMetrics;
|
|
if (metrics.sessionUptime > 0 && metrics.timeSinceLastPoll === null) {
|
|
const timeout = setTimeout(() => setServerReloadCounter(serverReloadCounter + 1), 5000);
|
|
return () => clearTimeout(timeout);
|
|
}
|
|
}, [serverConnectionSuccess, server]);
|
|
|
|
// invalid card/server selection
|
|
useEffect(() => {
|
|
if (userSelectedCard?.isValid() === false) {
|
|
setUserSelectedCard(null);
|
|
}
|
|
}, [userSelectedCard]);
|
|
|
|
// server info update
|
|
useEffect(() => {
|
|
const abortController = new AbortController();
|
|
fetchServerInfo(server.serverAddress, abortController.signal).then(r => {
|
|
if (!r) {
|
|
setServerConnectionSuccess(false);
|
|
return;
|
|
}
|
|
|
|
setServerConnectionSuccess(true);
|
|
|
|
realm.write(() => {
|
|
server.lastUsed = new Date();
|
|
Object.keys(r).forEach(x => server[x] = r[x]);
|
|
});
|
|
}).catch(() => {
|
|
setServerConnectionSuccess(false);
|
|
}).finally(() => {
|
|
setServerRefreshing(false);
|
|
});
|
|
|
|
const timeout = setTimeout(() => abortController.abort(), 5000);
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
abortController.abort();
|
|
}
|
|
}, [serverId, serverReloadCounter, realm]);
|
|
|
|
const cardInCallback = useCallback((card: AMCard) => {
|
|
const abortController = new AbortController();
|
|
const request: AMCardInRequest = {
|
|
cardId: card.code,
|
|
physicalCardIDm: card.physicalCardData?.cardIDm ?? undefined
|
|
};
|
|
|
|
performCardIn(server, request, abortController.signal).then(r => {
|
|
const now = new Date();
|
|
realm.write(() => {
|
|
server.lastUsed = now;
|
|
card.lastUsed = now;
|
|
|
|
if (server.gameId) {
|
|
// replace the "most recent card" record for the current game
|
|
realm.delete(realm.objects(AMCardRecentUse.schema.name).filtered("gameId = $0", server.gameId));
|
|
realm.create(AMCardRecentUse.schema.name, AMCardRecentUse.generate(server.gameId, card));
|
|
}
|
|
});
|
|
|
|
setCardInCompleted(r);
|
|
}).catch(() => {
|
|
setCardInCompleted(false);
|
|
}).finally(() => {
|
|
setTimeout(() => setCardInCompleted(null), 3000);
|
|
});
|
|
|
|
setTimeout(() => abortController.abort(), 5000);
|
|
return () => abortController.abort();
|
|
}, [server]);
|
|
|
|
const performPhysicalCardIn = useCallback(async () => {
|
|
const card = await felicaCardReadAsync();
|
|
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const savedCard = cards.find(x => x.physicalCardData?.cardIDm === card.idm);
|
|
|
|
if (savedCard) {
|
|
setUserSelectedCard(savedCard);
|
|
cardInCallback(savedCard);
|
|
return;
|
|
}
|
|
|
|
setCardInCompleted(false);
|
|
|
|
const cardId = card.type !== ICType.Amusement ? icCard0008Number(card.idm) : card.accessCode;
|
|
const abortController = new AbortController();
|
|
|
|
performCardIn(server, {cardId, physicalCardIDm: card.idm}, abortController.signal).then(r => {
|
|
setCardInCompleted(r);
|
|
}).catch(() => {
|
|
setCardInCompleted(false);
|
|
}).finally(() => {
|
|
setTimeout(() => setCardInCompleted(null), 3000);
|
|
});
|
|
|
|
setTimeout(() => abortController.abort(), 5000);
|
|
return () => abortController.abort();
|
|
}, [server]);
|
|
|
|
const performRefresh = () => {
|
|
setServerRefreshing(true);
|
|
setServerReloadCounter(serverReloadCounter + 1);
|
|
};
|
|
|
|
if (server?.isValid() !== true) {
|
|
return null;
|
|
}
|
|
|
|
if (serverConnectionSuccess === null) {
|
|
return (
|
|
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
|
|
<ActivityIndicator animating/>
|
|
<Paragraph>Connecting to server...</Paragraph>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ScrollView contentInsetAdjustmentBehavior="automatic"
|
|
refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
|
|
<View style={styles.container}>
|
|
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
|
<View style={styles.section}>
|
|
<Title
|
|
style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
|
|
{selectedCard ? <>
|
|
<ICCard actionDisabled
|
|
card={selectedCard}
|
|
actionIcon={<CircleCheck color="green" size={24}/>}>
|
|
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
|
</ICCard>
|
|
<View style={{display: "flex", flexDirection: "row", gap: 5}}>
|
|
<Button style={{flex: 1}}
|
|
textColor="white"
|
|
disabled={disableCardIn}
|
|
contentStyle={{flexDirection: 'row-reverse'}}
|
|
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
|
buttonColor={cardInCompleted === false ? "red" : "green"}
|
|
onPress={() => cardInCallback(selectedCard)}
|
|
icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"}
|
|
size={18}/>}>
|
|
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
|
</Button>
|
|
{nfcSupported === true && (
|
|
<IconButton
|
|
mode="outlined"
|
|
disabled={disableCardIn}
|
|
onPress={performPhysicalCardIn}
|
|
style={{borderRadius: 10, margin: 0, height: "100%", flex: 0.2}}
|
|
icon={() => <SmartphoneNfc
|
|
color={disableCardIn ? "gray" : theme.colors.onBackground} size={18}/>}/>
|
|
)}
|
|
</View>
|
|
</>
|
|
: <View style={styles.emptyPlaceholderView}>
|
|
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
|
|
<Button mode="text"
|
|
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
|
onPress={() => navigation.navigate('add-card')}>
|
|
Add Card
|
|
</Button>
|
|
</View>
|
|
}
|
|
</View>
|
|
|
|
{cards.length > 0 && (
|
|
<View style={styles.section}>
|
|
<Title style={styles.sectionTitle}>Additional Cards</Title>
|
|
<Button mode="contained"
|
|
textColor="white"
|
|
buttonColor={theme.colors.tertiary}
|
|
contentStyle={{flexDirection: 'row-reverse'}}
|
|
icon={() => <Plus color="white" size={18}/>}
|
|
onPress={() => navigation.navigate('add-card')}>
|
|
Add Card
|
|
</Button>
|
|
|
|
{unselectedCards.length
|
|
? unselectedCards.map((card: AMCard) => (
|
|
<ICCard card={card}
|
|
key={card._id.toHexString()}
|
|
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
|
onPress={() => setUserSelectedCard(card)}>
|
|
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
|
</ICCard>))
|
|
: <View style={styles.emptyPlaceholderView}>
|
|
<EmptyStatePlaceholder message="No additional cards"
|
|
icon={<SmartphoneNfcIcon color="gray"/>}/>
|
|
</View>}
|
|
</View>)}
|
|
</View>
|
|
</ScrollView>
|
|
)
|
|
}
|
|
|
|
function StatusAlert(props: {
|
|
server: AMServerConnectionInfo & Partial<AMServerInstanceMetrics>,
|
|
connectionEstablished: boolean
|
|
}) {
|
|
if (!props.connectionEstablished) {
|
|
return (
|
|
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
|
|
<Paragraph>
|
|
There was an issue connecting to <Paragraph
|
|
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Paragraph>.
|
|
Check the network connection and try again.
|
|
</Paragraph>
|
|
</AMUIStatusAlert>
|
|
)
|
|
}
|
|
|
|
// don't show if undefined (some older server versions)
|
|
if (props.server.timeSinceLastPoll === null) {
|
|
return (
|
|
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
|
|
<Paragraph>
|
|
Scanning a card below may not have any effect until the service has fully started up.
|
|
</Paragraph>
|
|
</AMUIStatusAlert>
|
|
)
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function DefaultCardDetail() {
|
|
return (
|
|
<>
|
|
{/* spacer */}
|
|
<View></View>
|
|
<AMUICardDetail>
|
|
<Star size={18} color="goldenrod"/>
|
|
<Text style={{color: "goldenrod"}}>Previously used with this game</Text>
|
|
</AMUICardDetail>
|
|
</>
|
|
|
|
)
|
|
}
|
|
|
|
const menuActions: MenuAction[] = [
|
|
{
|
|
id: "share",
|
|
title: "Share",
|
|
image: Platform.select({
|
|
ios: "square.and.arrow.up",
|
|
android: "share"
|
|
})
|
|
},
|
|
{
|
|
id: "delete",
|
|
title: "Delete Server",
|
|
attributes: {
|
|
destructive: true,
|
|
},
|
|
image: Platform.select({
|
|
ios: "trash",
|
|
android: "delete"
|
|
})
|
|
}
|
|
];
|
|
|
|
const styles = StyleSheet.create({
|
|
section: {
|
|
gap: 10
|
|
},
|
|
sectionTitle: {
|
|
fontWeight: "bold"
|
|
},
|
|
emptyPlaceholderView: {
|
|
marginVertical: 30,
|
|
gap: 15
|
|
},
|
|
container: {
|
|
paddingTop: Platform.select({android: 120, default: 0}),
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 80,
|
|
gap: 20
|
|
}
|
|
});
|