Files
ppc_amnet/amnet-native/app/ServerCardIn.tsx
T

365 lines
13 KiB
TypeScript

import {BSON} from "realm";
import {ActivityIndicator, Button, Text, useTheme} from "react-native-paper";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
import {Alert, Platform, RefreshControl, ScrollView, Share, StyleSheet, View} from "react-native";
import {
CircleCheck,
CircleDot,
CircleEllipsis,
CreditCard,
Plus,
SmartphoneNfcIcon,
WifiOff,
TvMinimalPlay,
Star,
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 {
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]);
// 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>(null);
const [cardInCompleted, setCardInCompleted] = useState<boolean | null>(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 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/>
<Text variant="bodyMedium">Connecting to server...</Text>
</View>
)
}
return (
<ScrollView contentInsetAdjustmentBehavior="automatic" refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
<View style={styles.container}>
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
<View style={styles.section}>
<Text variant="titleLarge" style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Text>
{selectedCard ? <>
<ICCard actionDisabled
card={selectedCard}
actionIcon={<CircleCheck color="green" size={24}/>}>
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
</ICCard>
<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>
</>
: <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}>
<Text variant="titleLarge" style={styles.sectionTitle}>Additional Cards</Text>
<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">
<Text variant="bodyMedium">
There was an issue connecting to <Text variant="bodyMedium"
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Text>.
Check the network connection and try again.
</Text>
</AMUIStatusAlert>
)
}
// don't show if undefined (some older server versions)
if (props.server.timeSinceLastPoll === null) {
return (
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
<Text variant="bodyMedium">
Scanning a card below may not have any effect until the service has fully started up.
</Text>
</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
}
});