Compare commits

..

27 Commits

Author SHA1 Message Date
David Westgate
2b7a9be089
Merge pull request #61 from djwesty/djwesty/60
Address skipped tests
2025-03-18 16:05:34 -07:00
David Westgate
b98d0035d8 remove unused imports 2025-03-18 15:23:25 -07:00
David Westgate
cf0b0332a4 remove uneeded test; fix other skipped tests 2025-03-18 15:21:06 -07:00
Mantasha Altab Noyela
e8898d8a60
Merge pull request #59 from djwesty/MantashaNoyela/22
Changed Styles and Screen Size for Mobile Device
2025-03-18 15:11:30 -07:00
David Westgate
2e5efda69a abstract picker component; added small buttons; visual tweaks 2025-03-17 23:48:31 -07:00
David Westgate
e720e2e010 global darkmode hook usage; use style abstractions; remove ui wrapper causing issues 2025-03-17 23:10:31 -07:00
MantashaNoyela
68a91a32ad Moved the dark mode to settings bar 2025-03-17 06:42:59 -07:00
MantashaNoyela
28addf53a5 Fixed issues 2025-03-16 16:08:07 -07:00
MantashaNoyela
533356117a Changed styles 2025-03-11 06:40:52 -07:00
Vutukuri15
da695be36d
Merge pull request #58 from djwesty/vutukuri15/57
Fixed issue with extra colors of image input # 57
2025-03-10 12:08:51 -07:00
vutukuri15
63ecde6c99 Fixed test code 2025-03-10 12:01:36 -07:00
vutukuri15
430750e3d4 Fixed issue with extra colors of image input 2025-03-09 20:54:47 -07:00
David Westgate
84a77ebb58
Merge pull request #56 from djwesty/djwesty/45
Bug Fix: Image Detection setting chip colors (Issue #45)
2025-03-09 20:19:42 -07:00
David Westgate
a07e0df947 fix 2025-03-09 18:35:35 -07:00
David Westgate
292cd7b797
Merge pull request #55 from djwesty/djwesty/40
Improve Chip Distribution (Issue #40)
2025-03-09 17:55:22 -07:00
David Westgate
de723a5d8a add warning mechanism 2025-03-09 16:35:02 -07:00
David Westgate
32ce2f9169 change default chip selections 2025-03-09 16:04:20 -07:00
David Westgate
648d815647 fix tests 2025-03-09 15:53:13 -07:00
David Westgate
abdffcef71 fix rounding issue 2025-03-09 15:45:20 -07:00
David Westgate
01303b625a stop rendering 0 dist chips 2025-03-09 15:10:14 -07:00
David Westgate
ca042b3afb update buyin label 2025-03-09 15:05:48 -07:00
David Westgate
bfa66d5856 limit ranges for buy in selector 2025-03-09 14:59:08 -07:00
David Westgate
f04496bf24 better distribution with fibbonachi 2025-03-09 14:30:45 -07:00
David Westgate
1216e76381 add back comments 2025-03-09 12:54:39 -07:00
Vutukuri15
31e0a7a995
Merge pull request #54 from djwesty/vutukuri15/53
Fixed text input box for white chips # 53
2025-03-08 12:16:06 -08:00
vutukuri15
c41db0db8b Fixed text input box for white chips 2025-03-07 11:37:48 -08:00
David Westgate
5265730c0c
Merge pull request #52 from djwesty/djwesty/51
Improve layout of decrement+increment buttons (Issue #51)
2025-03-07 10:39:36 -08:00
25 changed files with 540 additions and 339 deletions

View File

@ -9,7 +9,8 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.anonymous.pokerchipshelper"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

@ -1,9 +1,11 @@
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
import { COLORS } from "@/styles/styles";
import AppContext, { IAppContext } from "@/util/context"; import AppContext, { IAppContext } from "@/util/context";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { I18nextProvider, useTranslation } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import { useColorScheme } from "react-native";
const RootLayout: React.FC = () => { const RootLayout: React.FC = () => {
const [showSettings, setShowSettings] = useState<boolean>(false); const [showSettings, setShowSettings] = useState<boolean>(false);
@ -17,20 +19,36 @@ const RootLayout: React.FC = () => {
[showSettings] [showSettings]
); );
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return ( return (
<AppContext.Provider value={ctx}> <AppContext.Provider value={ctx}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Stack <Stack
screenOptions={{ screenOptions={{
contentStyle: {
backgroundColor: colors.BACKGROUND,
},
headerShown: true, headerShown: true,
title: t("poker_chips_helper"), title: t("poker_chips_helper"),
navigationBarColor: colors.PRIMARY,
headerRight: () => ( headerRight: () => (
<MaterialIcons <MaterialIcons
name="settings" name="settings"
onPress={() => setShowSettings(!showSettings)} onPress={() => setShowSettings(!showSettings)}
size={30} size={30}
color={colors.TEXT}
/> />
), ),
headerStyle: {
backgroundColor: colors.PRIMARY,
},
headerTintColor: colors.TEXT,
statusBarBackgroundColor: "grey",
}} }}
/> />
</I18nextProvider> </I18nextProvider>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext, useMemo } from "react"; import React, { useState, useEffect, useContext, useMemo } from "react";
import { ScrollView, Alert } from "react-native"; import { ScrollView, Alert, useColorScheme, Appearance } from "react-native";
import Button from "@/containers/Button"; import Button from "@/containers/Button";
import PlayerSelector from "@/components/PlayerSelector"; import PlayerSelector from "@/components/PlayerSelector";
import BuyInSelector from "@/components/BuyInSelector"; import BuyInSelector from "@/components/BuyInSelector";
@ -7,16 +7,17 @@ import ChipsSelector from "@/components/ChipsSelector";
import ChipDistributionSummary from "@/components/ChipDistributionSummary"; import ChipDistributionSummary from "@/components/ChipDistributionSummary";
import ChipDetection from "@/components/ChipDetection"; import ChipDetection from "@/components/ChipDetection";
import CurrencySelector from "@/components/CurrencySelector"; import CurrencySelector from "@/components/CurrencySelector";
import { saveState, loadState } from "@/components/CalculatorState"; import { saveState, loadState } from "@/util/CalculatorState";
import { import {
savePersistentState, savePersistentState,
loadPersistentState, loadPersistentState,
} from "@/components/PersistentState"; } from "@/util/PersistentState";
import styles from "@/styles/styles"; import styles, { COLORS } from "@/styles/styles";
import Section from "@/containers/Section"; import Section from "@/containers/Section";
import AppContext from "@/util/context"; import AppContext from "@/util/context";
import { Picker } from "@react-native-picker/picker";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
import { Picker, PickerItem } from "@/containers/Picker";
import { ItemValue } from "@react-native-picker/picker/typings/Picker";
const IndexScreen: React.FC = () => { const IndexScreen: React.FC = () => {
const [playerCount, setPlayerCount] = useState(2); const [playerCount, setPlayerCount] = useState(2);
@ -25,23 +26,25 @@ const IndexScreen: React.FC = () => {
const [totalChipsCount, setTotalChipsCount] = useState<number[]>([]); const [totalChipsCount, setTotalChipsCount] = useState<number[]>([]);
const [selectedCurrency, setSelectedCurrency] = useState<string>("$"); const [selectedCurrency, setSelectedCurrency] = useState<string>("$");
const [selectedLanguage, setSelectedLanguage] = useState<string>("en"); const [selectedLanguage, setSelectedLanguage] = useState<string>("en");
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
const context = useContext(AppContext); const context = useContext(AppContext);
const isSettingsVisible = useMemo(() => context.showSettings, [context]); const isSettingsVisible = useMemo(() => context.showSettings, [context]);
useEffect(() => { useEffect(() => {
const loadPersistentData = async () => { const loadPersistentData = async () => {
try { try {
console.log("Loading persistent game state...");
const savedState = await loadPersistentState(); const savedState = await loadPersistentState();
if (savedState) { if (savedState) {
console.log("Persistent state restored:", savedState);
setPlayerCount(savedState.playerCount || 2); setPlayerCount(savedState.playerCount || 2);
setBuyInAmount(savedState.buyInAmount || 20); setBuyInAmount(savedState.buyInAmount || 20);
setNumberOfChips(savedState.numberOfChips || 5); setNumberOfChips(savedState.numberOfChips || 5);
setTotalChipsCount(savedState.totalChipsCount || []); setTotalChipsCount(savedState.totalChipsCount || []);
setSelectedCurrency(savedState.selectedCurrency || "$"); setSelectedCurrency(savedState.selectedCurrency || "$");
} else {
console.log("No persistent state found, using defaults.");
} }
} catch (error) { } catch (error) {
console.error("Error loading persistent state:", error); console.error("Error loading persistent state:", error);
@ -62,19 +65,12 @@ const IndexScreen: React.FC = () => {
totalChipsCount, totalChipsCount,
selectedCurrency, selectedCurrency,
}; };
try {
await saveState(slot, state); await saveState(slot, state);
await savePersistentState(state); await savePersistentState(state);
console.log(`Game state saved to ${slot}:`, state); Alert.alert(i18n.t("success"), i18n.t("state_saved", { slot }));
Alert.alert(i18n.t("success"), i18n.t("state_saved", { slot })); // Fixed interpolation
} catch (error) {
console.error("Error saving state:", error);
Alert.alert(i18n.t("error"), i18n.t("failed_to_save_state"));
}
}; };
const handleLoad = async (slot: "SLOT1" | "SLOT2") => { const handleLoad = async (slot: "SLOT1" | "SLOT2") => {
try {
const loadedState = await loadState(slot); const loadedState = await loadState(slot);
if (loadedState) { if (loadedState) {
setPlayerCount(loadedState.playerCount); setPlayerCount(loadedState.playerCount);
@ -83,20 +79,15 @@ const IndexScreen: React.FC = () => {
setTotalChipsCount(loadedState.totalChipsCount); setTotalChipsCount(loadedState.totalChipsCount);
setSelectedCurrency(loadedState.selectedCurrency || "$"); setSelectedCurrency(loadedState.selectedCurrency || "$");
await savePersistentState(loadedState); await savePersistentState(loadedState);
console.log(`Game state loaded from ${slot}:`, loadedState); Alert.alert(i18n.t("success"), i18n.t("state_loaded_from", { slot }));
Alert.alert(i18n.t("success"), i18n.t("state_loaded_from", { slot })); // Fixed interpolation
} else { } else {
Alert.alert(i18n.t("info"), i18n.t("no_saved_state_found")); Alert.alert(i18n.t("info"), i18n.t("no_saved_state_found"));
} }
} catch (error) {
console.error("Error loading state:", error);
Alert.alert(i18n.t("error"), i18n.t("failed_to_load_state"));
}
}; };
const handleLanguageChange = (language: string) => { const handleLanguageChange = (language: ItemValue, _: any) => {
setSelectedLanguage(language); setSelectedLanguage(language.toString());
i18n.changeLanguage(language); i18n.changeLanguage(language.toString());
}; };
return ( return (
@ -105,6 +96,24 @@ const IndexScreen: React.FC = () => {
contentContainerStyle={styles.scrollViewContent} contentContainerStyle={styles.scrollViewContent}
> >
{isSettingsVisible && ( {isSettingsVisible && (
<>
<Section
title={i18n.t("appearance")}
iconName={"brightness-4"}
orientation="row"
>
<Button
title={
darkMode
? i18n.t("switch_to_light_mode")
: i18n.t("switch_to_dark_mode")
}
onPress={() =>
Appearance.setColorScheme(darkMode ? "light" : "dark")
}
/>
</Section>
<Section <Section
title={i18n.t("select_language")} title={i18n.t("select_language")}
iconName={"language"} iconName={"language"}
@ -113,15 +122,12 @@ const IndexScreen: React.FC = () => {
<Picker <Picker
selectedValue={selectedLanguage} selectedValue={selectedLanguage}
onValueChange={handleLanguageChange} onValueChange={handleLanguageChange}
style={styles.picker}
> >
<Picker.Item label={i18n.t("english")} value="en" /> <PickerItem label={i18n.t("english")} value="en" />
<Picker.Item label={i18n.t("spanish")} value="es" /> <PickerItem label={i18n.t("spanish")} value="es" />
</Picker> </Picker>
</Section> </Section>
)}
{isSettingsVisible && (
<Section <Section
title={i18n.t("select_currency")} title={i18n.t("select_currency")}
iconName={"attach-money"} iconName={"attach-money"}
@ -132,6 +138,7 @@ const IndexScreen: React.FC = () => {
setSelectedCurrency={setSelectedCurrency} setSelectedCurrency={setSelectedCurrency}
/> />
</Section> </Section>
</>
)} )}
<Section <Section
@ -164,6 +171,7 @@ const IndexScreen: React.FC = () => {
updateChipCount={(chipData) => { updateChipCount={(chipData) => {
const chipCountArray = Object.values(chipData); const chipCountArray = Object.values(chipData);
setTotalChipsCount(chipCountArray); setTotalChipsCount(chipCountArray);
setNumberOfChips(chipCountArray.length);
}} }}
/> />
</Section> </Section>
@ -204,19 +212,23 @@ const IndexScreen: React.FC = () => {
title={i18n.t("save_slot_1")} title={i18n.t("save_slot_1")}
onPress={() => handleSave("SLOT1")} onPress={() => handleSave("SLOT1")}
disabled={buyInAmount === null} disabled={buyInAmount === null}
size="small"
/> />
<Button <Button
title={i18n.t("save_slot_2")} title={i18n.t("save_slot_2")}
onPress={() => handleSave("SLOT2")} onPress={() => handleSave("SLOT2")}
disabled={buyInAmount === null} disabled={buyInAmount === null}
size="small"
/> />
<Button <Button
title={i18n.t("load_slot_1")} title={i18n.t("load_slot_1")}
onPress={() => handleLoad("SLOT1")} onPress={() => handleLoad("SLOT1")}
size="small"
/> />
<Button <Button
title={i18n.t("load_slot_2")} title={i18n.t("load_slot_2")}
onPress={() => handleLoad("SLOT2")} onPress={() => handleLoad("SLOT2")}
size="small"
/> />
</> </>
</Section> </Section>

View File

@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { View, Text, TextInput } from "react-native"; import { View, Text, TextInput, useColorScheme } from "react-native";
import styles, { COLORS } from "@/styles/styles"; import styles, { COLORS } from "@/styles/styles";
import Button from "@/containers/Button"; import Button from "@/containers/Button";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
@ -10,6 +10,14 @@ interface BuyInSelectorProps {
} }
const defaultBuyInOptions = [10, 25, 50]; const defaultBuyInOptions = [10, 25, 50];
const MIN = 1;
const MAX = 200;
const parseRoundClamp = (num: string): number => {
const parsed = parseFloat(num);
const rounded = Math.round(parsed);
return Math.min(Math.max(rounded, MIN), MAX);
};
const BuyInSelector: React.FC<BuyInSelectorProps> = ({ const BuyInSelector: React.FC<BuyInSelectorProps> = ({
setBuyInAmount, setBuyInAmount,
@ -17,11 +25,17 @@ const BuyInSelector: React.FC<BuyInSelectorProps> = ({
}) => { }) => {
const [customAmount, setCustomAmount] = useState(""); const [customAmount, setCustomAmount] = useState("");
const [buyInAmount, setBuyInAmountState] = useState<number | null>(null); const [buyInAmount, setBuyInAmountState] = useState<number | null>(null);
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
const handleCustomAmountChange = (value: string) => { const handleCustomAmountChange = (value: string) => {
const numericValue = parseFloat(value); const numericValue = parseRoundClamp(value);
if (!isNaN(numericValue) && numericValue >= 0) { if (!isNaN(numericValue) && numericValue >= 0) {
setCustomAmount(value); setCustomAmount(numericValue.toString());
setBuyInAmountState(numericValue); setBuyInAmountState(numericValue);
setBuyInAmount(numericValue); setBuyInAmount(numericValue);
} else { } else {
@ -43,25 +57,24 @@ const BuyInSelector: React.FC<BuyInSelectorProps> = ({
{defaultBuyInOptions.map((amount) => ( {defaultBuyInOptions.map((amount) => (
<Button <Button
key={amount} key={amount}
color={buyInAmount === amount ? COLORS.PRIMARY : COLORS.SECONDARY}
onPress={() => handleBuyInSelection(amount)} onPress={() => handleBuyInSelection(amount)}
title={`${selectedCurrency} ${amount}`} title={`${selectedCurrency} ${amount}`}
/> />
))} ))}
</View> </View>
<Text style={styles.p}>{i18n.t("custom_buy_in")}</Text>
<TextInput <TextInput
style={styles.input} style={[styles.input, { color: colors.TEXT }]}
placeholderTextColor={colors.TEXT}
value={customAmount} value={customAmount}
maxLength={3}
onChangeText={handleCustomAmountChange} onChangeText={handleCustomAmountChange}
placeholder={i18n.t("enter_custom_buy_in")} placeholder={`${i18n.t("custom_buy_in")} ${MIN} - ${MAX}`}
keyboardType="numeric" keyboardType="numeric"
/> />
<Text style={styles.h2}> <Text style={[styles.h2, { color: colors.TEXT }]}>
{`${i18n.t("selected_buy_in")}: `} {`${i18n.t("selected_buy_in")} `}
{buyInAmount !== null {buyInAmount !== null
? `${selectedCurrency} ${buyInAmount}` ? `${selectedCurrency} ${buyInAmount}`
: i18n.t("none")} : i18n.t("none")}

View File

@ -16,6 +16,8 @@ const ChipDetection = ({
Record<string, number> Record<string, number>
>({}); >({});
const chipColors = ["white", "red", "green", "blue", "black"];
const requestCameraPermissions = async () => { const requestCameraPermissions = async () => {
const cameraPermission = await ImagePicker.requestCameraPermissionsAsync(); const cameraPermission = await ImagePicker.requestCameraPermissionsAsync();
return cameraPermission.granted; return cameraPermission.granted;
@ -92,15 +94,25 @@ const ChipDetection = ({
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.choices || !result.choices[0].message) { if (!response.ok || !result.choices || !result.choices[0].message) {
throw new Error(i18n.t("invalid_response")); // Translate invalid response error throw new Error(i18n.t("invalid_response"));
} }
const rawContent = result.choices[0].message.content.trim(); const rawContent = result.choices[0].message.content.trim();
const cleanJSON = rawContent.replace(/```json|```/g, "").trim(); const cleanJSON = rawContent.replace(/```json|```/g, "").trim();
const parsedData: Record<string, number> = JSON.parse(cleanJSON); const parsedData: Record<string, number> = JSON.parse(cleanJSON);
const filteredData = Object.fromEntries( const filteredData = Object.entries(parsedData)
Object.entries(parsedData).filter(([_, count]) => count > 0) .filter(([color]) => chipColors.includes(color))
.sort(
([colorA], [colorB]) =>
chipColors.indexOf(colorA) - chipColors.indexOf(colorB)
)
.reduce(
(acc, [color, count]) => {
acc[color] = count;
return acc;
},
{} as Record<string, number>
); );
setLastDetectedChips(filteredData); setLastDetectedChips(filteredData);

View File

@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View, Text, StyleSheet } from "react-native"; import { View, Text, Alert } from "react-native";
import { ColorValue } from "react-native"; import { ColorValue } from "react-native";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
import styles from "@/styles/styles"; import styles, { COLORS } from "@/styles/styles";
import { MaterialIcons } from "@expo/vector-icons";
interface ChipDistributionSummaryProps { interface ChipDistributionSummaryProps {
playerCount: number; playerCount: number;
@ -12,6 +13,8 @@ interface ChipDistributionSummaryProps {
selectedCurrency: string; selectedCurrency: string;
} }
const reverseFib: number[] = [8, 5, 3, 2, 1];
const ChipDistributionSummary = ({ const ChipDistributionSummary = ({
playerCount, playerCount,
buyInAmount, buyInAmount,
@ -20,11 +23,15 @@ const ChipDistributionSummary = ({
selectedCurrency = "$", selectedCurrency = "$",
}: ChipDistributionSummaryProps) => { }: ChipDistributionSummaryProps) => {
const validDenominations: validDenomination[] = [ const validDenominations: validDenomination[] = [
0.05, 0.1, 0.25, 0.5, 1, 2, 2.5, 5, 10, 20, 50, 100, 0.05, 0.1, 0.25, 1, 5, 10, 20, 50, 100,
]; ];
const [denominations, setDenominations] = useState<validDenomination[]>([]); const [denominations, setDenominations] = useState<validDenomination[]>([]);
const [distributions, setDistributions] = useState<number[]>([]); const [distributions, setDistributions] = useState<number[]>([]);
const showAlert = () => {
Alert.alert(i18n.t("warning"), i18n.t("chip_value_warn"));
};
type validDenomination = type validDenomination =
| 0.05 | 0.05
| 0.1 | 0.1
@ -36,120 +43,112 @@ const ChipDistributionSummary = ({
| 5 | 5
| 10 | 10
| 20 | 20
| 25
| 50 | 50
| 100; | 100;
const findFloorDenomination = (target: number): validDenomination => { const findFloorDenomination = (target: number): validDenomination => {
let current: validDenomination = validDenominations[0]; let current: validDenomination = validDenominations[0];
validDenominations.forEach((value, index) => { validDenominations.forEach((value, _) => {
if (value < target) current = value; if (value < target) current = value;
}); });
return current; return current;
}; };
const maxDenomination = useMemo(() => { const round = useCallback((num: number) => Math.round(num * 100) / 100, []);
if (totalChipsCount.length > 3) {
return findFloorDenomination(buyInAmount / 3); // Bound for the value of the highest chip
} else { // This is somewhat arbitray and imperfect, but 1/3 to 1/5 is reasonable depending on the number of colors.
return findFloorDenomination(buyInAmount / 4); // Could be possibly improved based on value of buy in amount
const maxDenomination: validDenomination = useMemo(() => {
let max: validDenomination;
switch (totalChipsCount.length) {
case 5:
case 4:
max = findFloorDenomination(buyInAmount / 3);
break;
case 3:
max = findFloorDenomination(buyInAmount / 4);
break;
case 2:
case 1:
default:
max = findFloorDenomination(buyInAmount / 5);
break;
} }
}, [totalChipsCount]); return max;
}, [totalChipsCount, buyInAmount]);
const potValue = useMemo( const potValue = useMemo(
() => buyInAmount * playerCount, () => buyInAmount * playerCount,
[buyInAmount, playerCount] [buyInAmount, playerCount]
); );
// The total value of all chips distributed to a single player. Ideally should be equal to buyInAmount
const totalValue = useMemo(() => { const totalValue = useMemo(() => {
let value = 0; let value = 0;
for (let i = 0; i < totalChipsCount.length; i++) { for (let i = 0; i < distributions.length; i++) {
value += distributions[i] * denominations[i]; value += distributions[i] * denominations[i];
} }
return value; return value;
}, [distributions, denominations]); }, [distributions, denominations]);
// Maximum quantity of each chip color which may be distributed to a single player before running out
const maxPossibleDistribution = useMemo( const maxPossibleDistribution = useMemo(
() => totalChipsCount.map((v) => Math.floor(v / playerCount)), () => totalChipsCount.map((v) => Math.floor(v / playerCount)),
[totalChipsCount, playerCount] [totalChipsCount, playerCount]
); );
const redenominate = useCallback( // Dynamically set denominations and distributions from changing inputs
(
invalidDenomination: validDenomination[],
shuffleIndex: number
): validDenomination[] => {
let moved = false;
const newDenomination: validDenomination[] = [];
for (let i = invalidDenomination.length - 1; i >= 0; i--) {
if (i > shuffleIndex) {
newDenomination.push(invalidDenomination[i]);
} else if (i == shuffleIndex) {
newDenomination.push(invalidDenomination[i]);
} else if (i < shuffleIndex && !moved) {
const nextLowestDenominationIndex = Math.max(
validDenominations.indexOf(invalidDenomination[i]) - 1,
0
);
newDenomination.push(validDenominations[nextLowestDenominationIndex]);
moved = true;
} else {
newDenomination.push(invalidDenomination[i]);
}
}
newDenomination.reverse();
return newDenomination;
},
[]
);
useEffect(() => { useEffect(() => {
let testDenomination: validDenomination[] = []; let testDenomination: validDenomination[] = [];
const numColors = totalChipsCount.length; const totalNumColors = totalChipsCount.length;
const testDistribution: number[] = [];
for (let i = 0; i < numColors; ++i) {
testDistribution.push(0);
}
// Start with max denominations, then push on the next adjacent lower denomination
testDenomination.push(maxDenomination); testDenomination.push(maxDenomination);
let currentDenominationIndex: number = let currentDenominationIndex: number =
validDenominations.indexOf(maxDenomination); validDenominations.indexOf(maxDenomination);
for (let i = numColors - 2; i >= 0; i = i - 1) { for (
let i = totalNumColors - 2;
i >= 0 && currentDenominationIndex > 0;
i = i - 1
) {
currentDenominationIndex -= 1; currentDenominationIndex -= 1;
const currentDemoniation = validDenominations[currentDenominationIndex]; const currentDemoniation = validDenominations[currentDenominationIndex];
testDenomination.push(currentDemoniation); testDenomination.push(currentDemoniation);
} }
testDenomination.reverse(); testDenomination.reverse();
let numColors = testDenomination.length;
const testDistribution: number[] = [];
for (let i = 0; i < numColors; ++i) {
testDistribution.push(0);
}
// Distribute the chips using the test denomination with a reverse fibbonaci preference
// Not optimal, nor correct under all inputs but works for most inputs
// Algorithm could be improved with more complexity and optimization (re-tries, redenominating, etc.)
let remainingValue = buyInAmount; let remainingValue = buyInAmount;
let fail = true;
let failCount = 0;
while (fail && failCount < 1) {
let stop = false; let stop = false;
while (remainingValue > 0 && !stop) { while (remainingValue > 0 && !stop) {
let distributed = false; let distributed = false;
for (let i = numColors - 1; i >= 0; i = i - 1) { for (let i = numColors - 1; i >= 0; i = i - 1) {
if (testDistribution[i] < maxPossibleDistribution[i]) { for (
if (remainingValue >= testDenomination[i]) { let j = reverseFib[i];
j > 0 &&
remainingValue >= testDenomination[i] &&
testDistribution[i] < maxPossibleDistribution[i];
j = j - 1
) {
testDistribution[i] = testDistribution[i] + 1; testDistribution[i] = testDistribution[i] + 1;
remainingValue = remainingValue - testDenomination[i]; remainingValue = round(remainingValue - testDenomination[i]);
distributed = true; distributed = true;
} }
} }
}
if (distributed == false) { if (distributed == false) {
stop = true; stop = true;
} }
} }
if (remainingValue !== 0) {
const redenominateIndex = failCount % numColors;
testDenomination = redenominate(testDenomination, redenominateIndex);
failCount += 1;
fail = true;
} else {
fail = false;
}
}
setDenominations(testDenomination); setDenominations(testDenomination);
setDistributions(testDistribution); setDistributions(testDistribution);
}, [totalChipsCount, maxDenomination, buyInAmount, playerCount]); }, [totalChipsCount, maxDenomination, buyInAmount, playerCount]);
@ -157,24 +156,39 @@ const ChipDistributionSummary = ({
return ( return (
<> <>
<View style={styles.container}> <View style={styles.container}>
{totalChipsCount.map((_, index) => ( {distributions.map((distribution, index) => {
return (
distribution > 0 && (
<View style={{ flexDirection: "row" }} key={index}> <View style={{ flexDirection: "row" }} key={index}>
<Text <Text
style={{ style={{
...styles.h2, ...styles.h2,
fontWeight: "bold",
color: colors[index], color: colors[index],
...(colors[index] === "white" && styles.shadow), ...(colors[index] === "white" && styles.shadow),
}} }}
> >
{`${distributions[index]} ${i18n.t("chips")}: ${selectedCurrency}${denominations[index]} ${i18n.t("each")}`} {`${distribution} ${i18n.t("chips")}: ${selectedCurrency}${denominations[index]} ${i18n.t("each")}`}
</Text> </Text>
</View> </View>
))} )
);
})}
</View> </View>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}> <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<View style={[styles.container, { flexDirection: "row", gap: 1 }]}>
<Text style={styles.p}> <Text style={styles.p}>
{i18n.t("total_value")}: {selectedCurrency} {totalValue} {i18n.t("total_value")}: {selectedCurrency} {round(totalValue)}{" "}
</Text> </Text>
{round(totalValue) !== buyInAmount && (
<MaterialIcons
name="warning"
size={20}
color={COLORS.WARNING}
onPress={showAlert}
/>
)}
</View>
<Text style={styles.p}> <Text style={styles.p}>
{selectedCurrency} {potValue} {i18n.t("pot")} {selectedCurrency} {potValue} {i18n.t("pot")}
</Text> </Text>

View File

@ -14,6 +14,7 @@ import styles from "@/styles/styles";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
const colors: ColorValue[] = ["white", "red", "green", "blue", "black"]; const colors: ColorValue[] = ["white", "red", "green", "blue", "black"];
const defaults = [100, 50, 50, 50, 50];
const ChipInputModal = ({ const ChipInputModal = ({
showModal, showModal,
@ -29,12 +30,11 @@ const ChipInputModal = ({
const color: ColorValue = useMemo(() => showModal[1], [showModal]); const color: ColorValue = useMemo(() => showModal[1], [showModal]);
const colorIdx = useMemo(() => colors.indexOf(color), [color]); const colorIdx = useMemo(() => colors.indexOf(color), [color]);
const [value, setValue] = useState<number | undefined>(); // value may be undefined initially const [value, setValue] = useState<number | undefined>();
// Reset the color value when the specific color this modal is for, changes. The same modal is shared/reused in all cases.
useEffect(() => { useEffect(() => {
setValue(totalChipsCount[colorIdx]); setValue(totalChipsCount[colorIdx]);
}, [colorIdx]); }, [colorIdx, totalChipsCount]);
const shadow = useMemo(() => color === "white", [color]); const shadow = useMemo(() => color === "white", [color]);
@ -126,12 +126,12 @@ const ChipsSelector = ({
false, false,
colors[0], colors[0],
]); ]);
const colorsUsed = useMemo( const colorsUsed = useMemo(
() => colors.filter((v, i) => i < numberOfChips), () => colors.slice(0, numberOfChips),
[numberOfChips] [numberOfChips]
); );
// Callback for ChipInputModal to update the chips in the parent's state.
const update = useCallback( const update = useCallback(
(color: ColorValue, count: number) => { (color: ColorValue, count: number) => {
const newTotalChipsCount = totalChipsCount.slice(); const newTotalChipsCount = totalChipsCount.slice();
@ -139,20 +139,18 @@ const ChipsSelector = ({
newTotalChipsCount[colorIndex] = count; newTotalChipsCount[colorIndex] = count;
setTotalChipsCount(newTotalChipsCount); setTotalChipsCount(newTotalChipsCount);
}, },
[numberOfChips, totalChipsCount, setTotalChipsCount] [totalChipsCount, setTotalChipsCount]
); );
// When the number of chips changes (dec or inc), update the array being careful to add in sensible default values where they belong
useEffect(() => { useEffect(() => {
if (numberOfChips !== totalChipsCount.length) { if (numberOfChips !== totalChipsCount.length) {
let newTotalChipsCount = totalChipsCount.slice(); let newTotalChipsCount = totalChipsCount.slice();
if (numberOfChips < totalChipsCount.length) { if (numberOfChips < totalChipsCount.length) {
newTotalChipsCount = newTotalChipsCount.slice(0, numberOfChips); newTotalChipsCount = newTotalChipsCount.slice(0, numberOfChips);
} else if (numberOfChips > totalChipsCount.length) { } else if (numberOfChips > totalChipsCount.length) {
for (let colorIndex = 0; colorIndex < numberOfChips; ++colorIndex) { for (let colorIndex = 0; colorIndex < numberOfChips; ++colorIndex) {
if (colorIndex >= newTotalChipsCount.length) { if (colorIndex >= newTotalChipsCount.length) {
const defaultTotal = 100 - colorIndex * 20; const defaultTotal = defaults[colorIndex];
newTotalChipsCount.push(defaultTotal); newTotalChipsCount.push(defaultTotal);
} }
} }
@ -168,24 +166,27 @@ const ChipsSelector = ({
onPress={() => { onPress={() => {
setNumberOfChips(Math.max(1, numberOfChips - 1)); setNumberOfChips(Math.max(1, numberOfChips - 1));
}} }}
disabled={numberOfChips == 1} disabled={numberOfChips === 1}
/> />
<View style={[styles.container, { flexDirection: "row" }]}> <View style={[styles.container, { flexDirection: "row" }]}>
{colorsUsed.map((color) => ( {colorsUsed.map((color) => {
const chipCount = totalChipsCount[colors.indexOf(color)] ?? 0;
return (
<Chip <Chip
key={color.toString()} key={color.toString()}
color={color} color={color}
count={totalChipsCount[colors.indexOf(color)] ?? 0} count={chipCount}
setShowModal={setShowModal} setShowModal={setShowModal}
/> />
))} );
})}
</View> </View>
<Button <Button
title="+" title="+"
onPress={() => { onPress={() => {
setNumberOfChips(Math.min(5, numberOfChips + 1)); setNumberOfChips(Math.min(5, numberOfChips + 1));
}} }}
disabled={numberOfChips == 5} disabled={numberOfChips === 5}
/> />
<ChipInputModal <ChipInputModal
@ -198,35 +199,4 @@ const ChipsSelector = ({
); );
}; };
const styles1 = StyleSheet.create({
container: {
marginBottom: 20,
gap: 10,
},
title: {
fontWeight: "bold",
margin: "auto",
fontSize: 18,
},
chipContainer: {
padding: 20,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-evenly",
backgroundColor: "#bbb",
},
chip: {
textAlign: "center",
fontSize: 16,
fontWeight: "bold",
},
buttonContainer: {
display: "flex",
flexDirection: "row",
justifyContent: "space-evenly",
},
button: {},
});
export default ChipsSelector; export default ChipsSelector;

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { Picker } from "@react-native-picker/picker";
import styles from "@/styles/styles";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
import { Picker, PickerItem } from "@/containers/Picker";
interface CurrencySelectorProps { interface CurrencySelectorProps {
selectedCurrency: string; selectedCurrency: string;
@ -16,14 +15,13 @@ const CurrencySelector: React.FC<CurrencySelectorProps> = ({
<> <>
<Picker <Picker
selectedValue={selectedCurrency} selectedValue={selectedCurrency}
onValueChange={(itemValue) => setSelectedCurrency(itemValue)} onValueChange={(itemValue) => setSelectedCurrency(itemValue.toString())}
style={styles.picker}
testID="currency-picker" // ✅ Add testID here testID="currency-picker" // ✅ Add testID here
> >
<Picker.Item label={i18n.t("usd")} value="$" /> <PickerItem label={i18n.t("usd")} value="$" />
<Picker.Item label={i18n.t("euro")} value="€" /> <PickerItem label={i18n.t("euro")} value="€" />
<Picker.Item label={i18n.t("pound")} value="£" /> <PickerItem label={i18n.t("pound")} value="£" />
<Picker.Item label={i18n.t("inr")} value="₹" /> <PickerItem label={i18n.t("inr")} value="₹" />
</Picker> </Picker>
</> </>
); );

View File

@ -1,14 +1,16 @@
import React from "react"; import React, { useMemo } from "react";
import { View, Text } from "react-native"; import { View, Text, useColorScheme } from "react-native";
import Button from "@/containers/Button"; import Button from "@/containers/Button";
import styles from "@/styles/styles"; import styles, { COLORS } from "@/styles/styles";
interface PlayerSelectorProps { interface PlayerSelectorProps {
playerCount: number; playerCount: number;
setPlayerCount: React.Dispatch<React.SetStateAction<number>>; setPlayerCount: React.Dispatch<React.SetStateAction<number>>;
} }
const MIN = 2; const MIN = 2;
const MAX = 8; const MAX = 8;
const PlayerSelector: React.FC<PlayerSelectorProps> = ({ const PlayerSelector: React.FC<PlayerSelectorProps> = ({
playerCount, playerCount,
setPlayerCount, setPlayerCount,
@ -20,21 +22,27 @@ const PlayerSelector: React.FC<PlayerSelectorProps> = ({
const decreasePlayers = () => { const decreasePlayers = () => {
if (playerCount > MIN) setPlayerCount(playerCount - 1); if (playerCount > MIN) setPlayerCount(playerCount - 1);
}; };
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return ( return (
<> <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<Button <Button
title="-" title="-"
onPress={decreasePlayers} onPress={decreasePlayers}
disabled={playerCount <= MIN} disabled={playerCount <= MIN}
/> />
<Text style={styles.h1}>{playerCount}</Text> <Text style={[styles.h1, { color: colors.TEXT }]}>{playerCount}</Text>
<Button <Button
title="+" title="+"
onPress={increasePlayers} onPress={increasePlayers}
disabled={playerCount >= MAX} disabled={playerCount >= MAX}
/> />
</> </View>
); );
}; };

View File

@ -39,7 +39,9 @@ describe("BuyInSelector Component", () => {
expect(getByText("$ 10")).toBeTruthy(); expect(getByText("$ 10")).toBeTruthy();
expect(getByText("$ 25")).toBeTruthy(); expect(getByText("$ 25")).toBeTruthy();
expect(getByText("$ 50")).toBeTruthy(); expect(getByText("$ 50")).toBeTruthy();
expect(getByPlaceholderText("Enter custom buy-in")).toBeTruthy(); expect(
getByPlaceholderText("Or, enter a custom amount: 1 - 200")
).toBeTruthy();
expect(queryByText(/Selected Buy-in:.*None/i)).toBeTruthy(); expect(queryByText(/Selected Buy-in:.*None/i)).toBeTruthy();
}); });
@ -53,24 +55,36 @@ describe("BuyInSelector Component", () => {
it("sets a custom buy-in amount correctly", () => { it("sets a custom buy-in amount correctly", () => {
const { getByPlaceholderText } = renderComponent(); const { getByPlaceholderText } = renderComponent();
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "100"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"100"
);
expect(setBuyInAmount).toHaveBeenCalledWith(100); expect(setBuyInAmount).toHaveBeenCalledWith(100);
}); });
it("resets custom amount if invalid input is entered", () => { it("bound and validate custom amount if invalid input is entered", () => {
const { getByPlaceholderText } = renderComponent(); const { getByPlaceholderText } = renderComponent();
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-10"); fireEvent.changeText(
expect(setBuyInAmount).toHaveBeenCalledWith(25); // Default reset getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"-10"
);
expect(setBuyInAmount).toHaveBeenCalledWith(1); // Min value
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); fireEvent.changeText(
expect(setBuyInAmount).toHaveBeenCalledWith(25); getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"abc"
);
expect(setBuyInAmount).toHaveBeenCalledWith(1);
}); });
it("clears the custom amount when selecting a predefined option", () => { it("clears the custom amount when selecting a predefined option", () => {
const { getByPlaceholderText, getByText } = renderComponent(); const { getByPlaceholderText, getByText } = renderComponent();
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "100"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"100"
);
fireEvent.press(getByText("$ 50")); fireEvent.press(getByText("$ 50"));
expect(setBuyInAmount).toHaveBeenCalledWith(50); expect(setBuyInAmount).toHaveBeenCalledWith(50);
}); });
@ -78,13 +92,22 @@ describe("BuyInSelector Component", () => {
it("handles valid and invalid input for custom amount correctly", () => { it("handles valid and invalid input for custom amount correctly", () => {
const { getByPlaceholderText } = renderComponent(); const { getByPlaceholderText } = renderComponent();
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "75"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"75"
);
expect(setBuyInAmount).toHaveBeenCalledWith(75); expect(setBuyInAmount).toHaveBeenCalledWith(75);
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-5"); fireEvent.changeText(
expect(setBuyInAmount).toHaveBeenCalledWith(25); getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"-5"
);
expect(setBuyInAmount).toHaveBeenCalledWith(1);
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"abc"
);
expect(setBuyInAmount).toHaveBeenCalledWith(25); expect(setBuyInAmount).toHaveBeenCalledWith(25);
}); });
@ -98,7 +121,7 @@ describe("BuyInSelector Component", () => {
it("resets to default buy-in when custom input is cleared", () => { it("resets to default buy-in when custom input is cleared", () => {
const { getByPlaceholderText } = renderComponent(); const { getByPlaceholderText } = renderComponent();
const input = getByPlaceholderText("Enter custom buy-in"); const input = getByPlaceholderText("Or, enter a custom amount: 1 - 200");
fireEvent.changeText(input, "75"); fireEvent.changeText(input, "75");
expect(setBuyInAmount).toHaveBeenCalledWith(75); expect(setBuyInAmount).toHaveBeenCalledWith(75);
@ -110,7 +133,10 @@ describe("BuyInSelector Component", () => {
it("updates state correctly when selecting predefined buy-in after entering a custom amount", () => { it("updates state correctly when selecting predefined buy-in after entering a custom amount", () => {
const { getByPlaceholderText, getByText } = renderComponent(); const { getByPlaceholderText, getByText } = renderComponent();
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "200"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"200"
);
expect(setBuyInAmount).toHaveBeenCalledWith(200); expect(setBuyInAmount).toHaveBeenCalledWith(200);
fireEvent.press(getByText("$ 10")); fireEvent.press(getByText("$ 10"));

View File

@ -40,7 +40,7 @@ describe("ChipDetection", () => {
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); // Reset all mocks to prevent test contamination jest.restoreAllMocks();
}); });
it("renders correctly", () => { it("renders correctly", () => {
@ -83,7 +83,11 @@ describe("ChipDetection", () => {
fireEvent.press(getByText(/take a photo/i)); fireEvent.press(getByText(/take a photo/i));
await waitFor(() => await waitFor(() =>
expect(mockUpdateChipCount).toHaveBeenCalledWith({ red: 5, green: 3 }) expect(mockUpdateChipCount).toHaveBeenCalledWith({
red: 5,
green: 3,
blue: 0,
})
); );
}); });
@ -144,7 +148,7 @@ describe("ChipDetection", () => {
choices: [ choices: [
{ {
message: { message: {
content: JSON.stringify({ red: 5, green: 3 }), content: JSON.stringify({ red: 5, green: 3, blue: 0 }),
}, },
}, },
], ],
@ -163,6 +167,7 @@ describe("ChipDetection", () => {
expect(mockUpdateChipCount).toHaveBeenCalledWith({ expect(mockUpdateChipCount).toHaveBeenCalledWith({
red: 5, red: 5,
green: 3, green: 3,
blue: 0,
}) })
); );
}); });

View File

@ -1,14 +1,23 @@
import React from "react"; import React from "react";
import { render } from "@testing-library/react-native"; import { Alert } from "react-native";
import { fireEvent, render } from "@testing-library/react-native";
import ChipDistributionSummary from "../ChipDistributionSummary"; import ChipDistributionSummary from "../ChipDistributionSummary";
jest.mock("@expo/vector-icons", () => {
const { Text } = require("react-native");
return {
MaterialIcons: () => <Text>TestIcon</Text>,
};
});
describe("ChipDistributionSummary Component", () => { describe("ChipDistributionSummary Component", () => {
test("renders correctly with valid data", () => { test("renders correctly with valid data", () => {
const playerCount = 4; const playerCount = 4;
const totalChipsCount = [100, 80, 60, 40, 20]; const totalChipsCount = [100, 80, 60, 40, 20];
const buyInAmount = 20; const buyInAmount = 20;
const expectedDistribution = [2, 2, 1, 2, 2]; const expectedDistribution = [16, 12, 8, 6, 2];
const expectedDenominations = [0.5, 1, 2, 2.5, 5]; const expectedDenominations = [0.05, 0.1, 0.25, 1, 5];
const { getByText } = render( const { getByText } = render(
<ChipDistributionSummary <ChipDistributionSummary
@ -28,46 +37,23 @@ describe("ChipDistributionSummary Component", () => {
}); });
}); });
test.skip("renders fallback message when no valid distribution", () => { test("renders warning message when needed", async () => {
const { getByText } = render( const { getByText } = render(
<ChipDistributionSummary <ChipDistributionSummary
playerCount={0} playerCount={6}
buyInAmount={20} buyInAmount={25}
selectedCurrency={"$"} selectedCurrency={"$"}
totalChipsCount={[]} totalChipsCount={[100, 50]}
/> />
); );
expect(getByText("No valid distribution calculated yet.")).toBeTruthy(); const warning = getByText("TestIcon");
}); expect(warning).toBeTruthy();
test.skip("scales down chips if exceeding MAX_CHIPS", () => { jest.spyOn(Alert, "alert");
const playerCount = 2; fireEvent.press(warning);
let totalChipsCount = [300, 400, 500, 600, 700]; expect(Alert.alert).toHaveBeenCalledWith(
const MAX_CHIPS = 500; "Warning",
const totalChips = totalChipsCount.reduce((sum, count) => sum + count, 0); `Be advised that the value of the distributed chips does not equal the buy-in for these inputs.\n\nHowever, results shown are fair to all players`
if (totalChips > MAX_CHIPS) {
const scaleFactor = MAX_CHIPS / totalChips;
totalChipsCount = totalChipsCount.map((count) =>
Math.round(count * scaleFactor)
); );
}
const expectedDistribution = [30, 40, 50, 60, 70]; // Adjust as per actual component calculations
const { getByText } = render(
<ChipDistributionSummary
playerCount={playerCount}
buyInAmount={100}
totalChipsCount={totalChipsCount}
selectedCurrency={"$"}
/>
);
expect(getByText("Distribution & Denomination")).toBeTruthy();
expectedDistribution.forEach((count) => {
expect(getByText(new RegExp(`^${count}\\s+chips:`, "i"))).toBeTruthy();
});
}); });
}); });

View File

@ -3,9 +3,7 @@ import {
userEvent, userEvent,
render, render,
screen, screen,
waitForElementToBeRemoved,
fireEvent, fireEvent,
act,
} from "@testing-library/react-native"; } from "@testing-library/react-native";
import ChipsSelector from "@/components/ChipsSelector"; import ChipsSelector from "@/components/ChipsSelector";
@ -82,27 +80,16 @@ describe("tests for ChipsSelector", () => {
TOTAL_CHIPS_COUNT[4], TOTAL_CHIPS_COUNT[4],
]); ]);
}); });
// skip: There is a jest/DOM issue with the button interaction, despite working correctly in-app. Documented to resolve.
it.skip("test dec/inc buttons", async () => {
rend();
const blue = screen.getByText(TOTAL_CHIPS_COUNT[3].toString()); it("test dec/inc buttons", async () => {
const black = screen.getByText(TOTAL_CHIPS_COUNT[4].toString()); rend();
const decrement = screen.getByRole("button", { name: /-/i }); const decrement = screen.getByRole("button", { name: /-/i });
const increment = screen.getByRole("button", { name: /\+/i }); const increment = screen.getByRole("button", { name: /\+/i });
fireEvent.press(decrement); fireEvent.press(decrement);
fireEvent.press(decrement); expect(mockSetNumberOfChips).toHaveBeenCalledWith(4);
// Test that elements are removed after fireEvent
await waitForElementToBeRemoved(() => blue);
await waitForElementToBeRemoved(() => black);
fireEvent.press(increment); fireEvent.press(increment);
fireEvent.press(increment); expect(mockSetNumberOfChips).toHaveBeenCalledWith(4);
// Test that new elements re-appear, correctly
const blue1 = screen.getByText(TOTAL_CHIPS_COUNT[3].toString());
const black1 = screen.getByText(TOTAL_CHIPS_COUNT[4].toString());
}); });
}); });

View File

@ -1,9 +1,82 @@
import { ButtonProps, Button } from "react-native";
import { COLORS } from "@/styles/styles"; import { COLORS } from "@/styles/styles";
import React, { useMemo } from "react";
import {
Text,
TouchableOpacity,
StyleSheet,
useColorScheme,
} from "react-native";
// More styling can be done, or swap this out with more flexible component like a TouchableOpacity if needed interface ButtonProps {
const AppButton = (props: ButtonProps) => ( title: string;
<Button color={COLORS.PRIMARY} {...props} /> onPress: () => void;
disabled?: boolean;
size?: "normal" | "small";
}
const Button: React.FC<ButtonProps> = ({
title,
onPress,
disabled,
size = "normal",
}) => {
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
); );
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
style={[
size == "normal" ? styles.button : styles.buttonSmall,
{ backgroundColor: colors.PRIMARY },
disabled && styles.disabled,
]}
>
<Text
style={[
size == "normal" ? styles.buttonText : styles.buttonTextSmall,
{ color: colors.TEXT },
]}
>
{title}
</Text>
</TouchableOpacity>
);
};
export default AppButton; const styles = StyleSheet.create({
button: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 8,
marginHorizontal: 5,
marginVertical: 5,
},
buttonText: {
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
},
buttonSmall: {
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 8,
marginHorizontal: 2,
marginVertical: 2,
},
buttonTextSmall: {
fontSize: 12,
fontWeight: "bold",
textAlign: "center",
},
disabled: {
opacity: 0.5,
},
});
export default Button;

49
containers/Picker.tsx Normal file
View File

@ -0,0 +1,49 @@
import styles, { COLORS } from "@/styles/styles";
import { PickerItemProps, PickerProps } from "@react-native-picker/picker";
import { useMemo } from "react";
import { useColorScheme, View } from "react-native";
import { Picker as RNPicker } from "@react-native-picker/picker";
export const PickerItem: React.FC<PickerItemProps> = (props) => {
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return (
<RNPicker.Item
style={[
styles.pickerItem,
{ color: colors.TEXT, backgroundColor: colors.PRIMARY },
]}
{...props}
/>
);
};
export const Picker: React.FC<PickerProps> = (props) => {
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return (
<View style={styles.pickerWrapper}>
<RNPicker
style={[
styles.picker,
{
color: colors.TEXT,
backgroundColor: colors.PRIMARY,
},
]}
dropdownIconColor={colors.TEXT}
{...props}
/>
</View>
);
};

View File

@ -1,7 +1,7 @@
import { View, Text, StyleSheet } from "react-native"; import { View, Text, StyleSheet, useColorScheme } from "react-native";
import React from "react"; import React, { useMemo } from "react";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import globalStyles from "@/styles/styles"; import globalStyles, { COLORS } from "@/styles/styles";
const titleCase = (input: string) => const titleCase = (input: string) =>
input input
@ -23,6 +23,12 @@ const Section = ({
orientation?: "row" | "column"; orientation?: "row" | "column";
contentStyle?: object; contentStyle?: object;
}) => { }) => {
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
@ -30,9 +36,11 @@ const Section = ({
style={styles.icon} style={styles.icon}
name={iconName} name={iconName}
size={30} size={30}
color={"black"} color={colors.TEXT}
/> />
<Text style={styles.title}>{titleCase(title)}</Text> <Text style={[styles.title, { color: colors.TEXT }]}>
{titleCase(title)}
</Text>
</View> </View>
<View <View
style={{ style={{

View File

@ -8,8 +8,7 @@
"inr": "INR (₹)", "inr": "INR (₹)",
"select_number_of_players": "Select the Number of Players:", "select_number_of_players": "Select the Number of Players:",
"select_buyin_amount": "Select Buy-in Amount:", "select_buyin_amount": "Select Buy-in Amount:",
"custom_buy_in": "Or enter a custom amount:", "custom_buy_in": "Or, enter a custom amount:",
"enter_custom_buy_in": "Enter custom buy-in",
"selected_buy_in": "Selected Buy-in:", "selected_buy_in": "Selected Buy-in:",
"none": "None", "none": "None",
"pick_an_image": "Pick an image", "pick_an_image": "Pick an image",
@ -37,6 +36,7 @@
"failed_to_save_state": "Failed to save state.", "failed_to_save_state": "Failed to save state.",
"state_loaded_from": "State loaded from", "state_loaded_from": "State loaded from",
"info": "Info", "info": "Info",
"warning": "Warning",
"no_saved_state_found": "No saved state found.", "no_saved_state_found": "No saved state found.",
"automatic_chip_detection": "Automatic Chip Detection", "automatic_chip_detection": "Automatic Chip Detection",
"manual_chip_adjustment": "Manual Chip Adjustment", "manual_chip_adjustment": "Manual Chip Adjustment",
@ -45,6 +45,10 @@
"save_slot_2": "Save\nSlot 2", "save_slot_2": "Save\nSlot 2",
"load_slot_1": "Load\nSlot 1", "load_slot_1": "Load\nSlot 1",
"load_slot_2": "Load\nSlot 2", "load_slot_2": "Load\nSlot 2",
"please_select_valid_buyin": "Please select a valid buy-in amount" "please_select_valid_buyin": "Please select a valid buy-in amount",
"chip_value_warn": "Be advised that the value of the distributed chips does not equal the buy-in for these inputs.\n\nHowever, results shown are fair to all players",
"appearance": "Appearance",
"switch_to_dark_mode": "Switch to Dark Mode",
"switch_to_light_mode": "Switch to Light Mode"
} }
} }

View File

@ -8,8 +8,7 @@
"inr": "INR (₹)", "inr": "INR (₹)",
"select_number_of_players": "Seleccionar número de jugadores:", "select_number_of_players": "Seleccionar número de jugadores:",
"select_buyin_amount": "Seleccionar cantidad de buy-in:", "select_buyin_amount": "Seleccionar cantidad de buy-in:",
"custom_buy_in": "O ingresa una cantidad personalizada:", "custom_buy_in": "O, ingresa una cantidad personalizada:",
"enter_custom_buy_in": "Ingresar buy-in personalizado",
"selected_buy_in": "Buy-in seleccionado:", "selected_buy_in": "Buy-in seleccionado:",
"none": "Ninguno", "none": "Ninguno",
"pick_an_image": "Elige una imagen", "pick_an_image": "Elige una imagen",
@ -38,6 +37,7 @@
"failed_to_save_state": "No se pudo guardar el estado.", "failed_to_save_state": "No se pudo guardar el estado.",
"state_loaded_from": "Estado cargado desde", "state_loaded_from": "Estado cargado desde",
"info": "Información", "info": "Información",
"warning": "Advertencia",
"no_saved_state_found": "No se encontró estado guardado.", "no_saved_state_found": "No se encontró estado guardado.",
"automatic_chip_detection": "Detección automática de fichas", "automatic_chip_detection": "Detección automática de fichas",
"manual_chip_adjustment": "Ajuste manual de fichas", "manual_chip_adjustment": "Ajuste manual de fichas",
@ -46,6 +46,10 @@
"save_slot_2": "Guardar\nSlot 2", "save_slot_2": "Guardar\nSlot 2",
"load_slot_1": "Cargar\nSlot 1", "load_slot_1": "Cargar\nSlot 1",
"load_slot_2": "Cargar\nSlot 2", "load_slot_2": "Cargar\nSlot 2",
"please_select_valid_buyin": "Por favor seleccione una cantidad de buy-in válida" "please_select_valid_buyin": "Por favor seleccione una cantidad de buy-in válida",
"chip_value_warn": "Tenga en cuenta que el valor de las fichas distribuidas no es igual al buy-in para estas entradas.\n\nSin embargo, los resultados que se muestran son justos para todos los jugadores.",
"appearance": "Apariencia",
"switch_to_dark_mode": "Cambiar a Modo Oscuro",
"switch_to_light_mode": "Cambiar a Modo Claro"
} }
} }

2
package-lock.json generated
View File

@ -13,7 +13,7 @@
"@react-native-picker/picker": "^2.11.0", "@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "7.2.0", "@react-navigation/bottom-tabs": "7.2.0",
"@react-navigation/native": "7.0.14", "@react-navigation/native": "7.0.14",
"expo": "52.0.37", "expo": "^52.0.37",
"expo-blur": "14.0.3", "expo-blur": "14.0.3",
"expo-constants": "17.0.7", "expo-constants": "17.0.7",
"expo-file-system": "18.0.11", "expo-file-system": "18.0.11",

View File

@ -23,7 +23,7 @@
"@react-native-picker/picker": "^2.11.0", "@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "7.2.0", "@react-navigation/bottom-tabs": "7.2.0",
"@react-navigation/native": "7.0.14", "@react-navigation/native": "7.0.14",
"expo": "52.0.37", "expo": "^52.0.37",
"expo-blur": "14.0.3", "expo-blur": "14.0.3",
"expo-constants": "17.0.7", "expo-constants": "17.0.7",
"expo-file-system": "18.0.11", "expo-file-system": "18.0.11",
@ -31,6 +31,7 @@
"expo-haptics": "14.0.1", "expo-haptics": "14.0.1",
"expo-image-picker": "16.0.6", "expo-image-picker": "16.0.6",
"expo-linking": "7.0.5", "expo-linking": "7.0.5",
"expo-localization": "~16.0.1",
"expo-router": "4.0.17", "expo-router": "4.0.17",
"expo-splash-screen": "0.29.22", "expo-splash-screen": "0.29.22",
"expo-status-bar": "2.0.1", "expo-status-bar": "2.0.1",
@ -47,8 +48,7 @@
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "4.4.0", "react-native-screens": "4.4.0",
"react-native-web": "0.19.13", "react-native-web": "0.19.13",
"react-native-webview": "13.12.5", "react-native-webview": "13.12.5"
"expo-localization": "~16.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.26.9", "@babel/core": "7.26.9",

View File

@ -1,16 +1,23 @@
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
export const COLORS = { export const COLORS = {
PRIMARY: "#007bff", WARNING: "#c79c28",
SECONDARY: "#6c757d", LIGHT: {
SUCCESS: "#28a745", TEXT: "black",
DANGER: "#dc3545", PRIMARY: "lightgrey",
WARNING: "#ffc107", SECONDARY: "azure",
BACKGROUND: "ghostwhite",
DISABLED: "gray",
},
DARK: {
TEXT: "white",
PRIMARY: "black",
SECONDARY: "teal",
BACKGROUND: "dimgrey",
DISABLED: "gray",
},
}; };
const lightStyles = StyleSheet.create({});
const darkStyles = StyleSheet.create({});
const GlobalStyles = StyleSheet.create({ const GlobalStyles = StyleSheet.create({
scrollView: {}, scrollView: {},
scrollViewContent: { scrollViewContent: {
@ -25,7 +32,7 @@ const GlobalStyles = StyleSheet.create({
gap: 10, gap: 10,
}, },
h1: { fontSize: 20, fontWeight: "bold" }, h1: { fontSize: 19, fontWeight: "bold" },
h2: { fontSize: 18, fontWeight: "normal" }, h2: { fontSize: 18, fontWeight: "normal" },
p: { p: {
fontSize: 16, fontSize: 16,
@ -51,9 +58,15 @@ const GlobalStyles = StyleSheet.create({
textShadowRadius: 10, textShadowRadius: 10,
}, },
picker: { picker: {
height: 50, borderRadius: 10,
height: 55,
width: 150, width: 150,
}, },
pickerItem: {},
pickerWrapper: {
borderRadius: 10,
overflow: "hidden",
},
}); });
export default GlobalStyles; export default GlobalStyles;

View File

@ -1,12 +1,12 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { saveState, loadState, PokerState } from "../CalculatorState"; import { saveState, loadState, PokerState } from "@/util/CalculatorState";
// Mock AsyncStorage // Mock AsyncStorage
jest.mock("@react-native-async-storage/async-storage", () => jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock") require("@react-native-async-storage/async-storage/jest/async-storage-mock")
); );
describe("CalculatorState.tsx", () => { describe("CalculatorState.ts", () => {
const mockState: PokerState = { const mockState: PokerState = {
playerCount: 4, playerCount: 4,
buyInAmount: 50, buyInAmount: 50,

View File

@ -3,14 +3,14 @@ import {
savePersistentState, savePersistentState,
loadPersistentState, loadPersistentState,
PokerState, PokerState,
} from "../PersistentState"; } from "@/util/PersistentState";
jest.mock("@react-native-async-storage/async-storage", () => ({ jest.mock("@react-native-async-storage/async-storage", () => ({
setItem: jest.fn(), setItem: jest.fn(),
getItem: jest.fn(), getItem: jest.fn(),
})); }));
describe("PersistentState.tsx", () => { describe("PersistentState.ts", () => {
const mockState: PokerState = { const mockState: PokerState = {
playerCount: 4, playerCount: 4,
buyInAmount: 50, buyInAmount: 50,