Compare commits

..

76 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
David Westgate
07cbfe7172 move buttons around; add optional style prop to section container 2025-03-07 09:18:25 -08:00
Vutukuri15
8b17649e96
Merge pull request #50 from djwesty/vutukuri15/47
Fixed typescript errors # 47
2025-03-05 19:10:36 -08:00
vutukuri15
868d9aec54 Fixed typescript errors 2025-03-05 12:47:51 -08:00
Vutukuri15
b7238d11d4
Merge pull request #49 from djwesty/vutukuri15/35
Implemented Language Selector # 35
2025-03-04 09:11:25 -08:00
vutukuri15
56ce3c9bc2 Implemented changes 2025-03-04 00:35:50 -08:00
vutukuri15
d27743017c Implemented Language Selector 2025-03-03 16:35:46 -08:00
David Westgate
93cc07c28c
Merge pull request #48 from djwesty/djwesty/39
Consistent Application Styling #39
2025-03-02 16:53:49 -08:00
David Westgate
4b27aea688 style refactor 2025-03-02 16:21:33 -08:00
Vutukuri15
8cc8f006df
Merge pull request #46 from djwesty/vutukuri15/34
Implemented Currency Selector # 34
2025-03-01 12:08:46 -08:00
vutukuri15
624d8b0b1d Updated index file 2025-02-28 20:58:36 -08:00
vutukuri15
2a3ffc8ca2 Removed unnecessary package 2025-02-28 19:59:29 -08:00
vutukuri15
29abd384d8 Modified requested changes 2025-02-28 19:33:46 -08:00
vutukuri15
297b0fc026 Implementd Currency Selector 2025-02-28 11:19:22 -08:00
Vutukuri15
41791516f7
Merge pull request #44 from djwesty/vutukuri15/43
Fixing icon #43
2025-02-27 18:37:10 -08:00
vutukuri15
a6c0c5c2d3 Fixing icon 2025-02-27 18:13:10 -08:00
David Westgate
ec5521c542
Merge pull request #42 from djwesty/djwesty/41
Added instructions to build APK
2025-02-27 14:50:10 -08:00
Vutukuri15
f7d97cc1c1
Merge pull request #36 from djwesty/vutukuri15/HWtest
Update Unit Tests for BuyInSelector Component
2025-02-27 14:47:23 -08:00
David Westgate
c65a3506bd fix typo 2025-02-27 12:26:15 -08:00
David Westgate
96fe517c6a added instructions to build APK 2025-02-26 22:38:49 -08:00
Mantasha Altab Noyela
25dc61006b
Merge pull request #38 from djwesty/MantashaNoyela/27
Restores the previous state when reopened
2025-02-26 22:01:48 -08:00
Mantasha Altab Noyela
1ebdaf5c9f
Merge branch 'main' into MantashaNoyela/27 2025-02-25 23:54:00 -08:00
Mantasha Altab Noyela
077f314f20
Merge pull request #37 from djwesty/MantashaNoyela/26
Ability to save and load calculator states into two slots
2025-02-25 23:28:55 -08:00
Mantasha Altab Noyela
9512d92448
Update package.json 2025-02-25 23:21:05 -08:00
Mantasha Altab Noyela
cb44965e30
Update index.tsx
I have updated the index file
2025-02-25 23:13:57 -08:00
Mantasha Altab Noyela
a3288e2a31
Update index.tsx 2025-02-25 22:20:02 -08:00
MantashaNoyela
d878e93eb4 Restores the previous state when reopened 2025-02-25 20:46:59 -08:00
MantashaNoyela
9952ea3687 Ability to save and load calculator states into two slots 2025-02-25 20:33:33 -08:00
vutukuri15
b1c9ce1ce2 Assignment6 test code 2025-02-25 18:49:25 -08:00
David Westgate
0aee45d534
Merge pull request #33 from djwesty/vutukuri15/6
Displaying Chip value contribution #6
2025-02-25 11:00:20 -08:00
David Westgate
85c4a9d518 fix tests 2025-02-24 22:21:28 -08:00
David Westgate
0f5d7316de optimizations and comments 2025-02-24 22:02:17 -08:00
David Westgate
4cc8db3e9c improve styles 2025-02-24 19:44:27 -08:00
David Westgate
8d3641095b somewhat working algorithm 2025-02-24 18:48:22 -08:00
David Westgate
c812ca066f Merge remote-tracking branch 'origin/main' into vutukuri15/6 2025-02-24 00:20:14 -08:00
Vutukuri15
ce222a0ed3
Merge pull request #32 from djwesty/vutukuri15/30
Fixed welcome screen issue # 30
2025-02-24 00:09:29 -08:00
David Westgate
349e936453 update doc+template; fix personal env issue with dep pinning 2025-02-24 00:03:18 -08:00
vutukuri15
150edf99a7 used api url from .env 2025-02-23 23:38:32 -08:00
vutukuri15
07c93e8800 Fixed Welcome Screen Issue 2025-02-23 23:38:32 -08:00
David Westgate
dd75f58519 more sophistication for invalid distriubtions 2025-02-23 23:20:36 -08:00
David Westgate
07721bc054
Merge pull request #29 from djwesty/djwesty/24
Application main page, remove top bar
2025-02-23 16:44:00 -08:00
David Westgate
2be8b1165a gitignore coverage 2025-02-23 15:05:01 -08:00
David Westgate
979b0abbff remove redundant title 2025-02-23 15:04:49 -08:00
Vutukuri15
a1ace37271
Merge pull request #28 from djwesty/vutukuri15/23
Add app title and app icon #23
2025-02-23 10:51:54 -08:00
vutukuri15
7d52b21b9b Set app title and add icon 2025-02-23 10:07:46 -08:00
Vutukuri15
6f10efaac0
Merge pull request #25 from djwesty/djwesty/7
Implemented Automatic Detection of chip counts and colors # 7
2025-02-21 21:44:37 -08:00
David Westgate
1a027e1feb more work in progress on algorithm 2025-02-21 18:17:07 -08:00
David Westgate
f2710588ea start rework of algorithm 2025-02-20 14:18:56 -08:00
vutukuri15
b9c90d2493 Modified code 2025-02-17 22:54:05 -08:00
vutukuri15
d9a835c315 Displaying Chip value contribution 2025-02-15 19:56:41 -08:00
35 changed files with 3019 additions and 1451 deletions

View File

@ -1,3 +1,4 @@
API_KEY=Put Open AI key here EXPO_PUBLIC_API_URL=https://api.openai.com/v1/chat/completions
GPT_MODEL=gpt-4o-mini EXPO_PUBLIC_API_KEY=put-open-ai-key-here
#GPT_MODEL=gpt-4-turbo # More expensive model, use sparingly EXPO_PUBLIC_MODEL_NAME=gpt-4o-mini
#EXPO_PUBLIC_MODEL_NAME=gpt-4-turbo # More expensive model, use sparingly

2
.gitignore vendored
View File

@ -37,4 +37,6 @@ yarn-error.*
app-example app-example
android android
ios
.env .env
coverage

View File

@ -18,14 +18,15 @@ This is an [Expo](https://expo.dev) project created with [`create-expo-app`](htt
To set up your environment variables: To set up your environment variables:
1. Copy the example environment variable file to create your own .env file: 1. Copy the example environment variable file to create your own `.env` file:
```bash
cp .env.example .env cp .env.example .env
```
2. Open the .env file and add your OpenAI API key: 2. Open the `.env` file and add your OpenAI API key:
API_KEY=your_openai_api_key_here `EXPO_PUBLIC_API_KEY=put-open-ai-key-here`
MODEL_NAME=your_model_name
3. Save the .env file. 3. Save the .env file.
@ -59,6 +60,19 @@ In the output, you'll find options to open the app in a
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
### Android APK build
To create an APK build, use the following
```bash
npx expo prebuild
cd android
./gradlew assembleRelease # linux
./gradlew.bat assembleRelease # windows command
```
Then, see `android/app/build/outputs/apk/release/app-release.apk` for the output
### Learn more ### Learn more
To learn more about developing your project with Expo, look at the following resources: To learn more about developing your project with Expo, look at the following resources:

View File

@ -1,6 +1,6 @@
{ {
"expo": { "expo": {
"name": "poker-chips-helper", "name": "Poker Chips Helper",
"slug": "poker-chips-helper", "slug": "poker-chips-helper",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
@ -9,11 +9,12 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.anonymous.pokerchipshelper"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"package": "com.anonymous.pokerchipshelper" "package": "com.anonymous.pokerchipshelper"
@ -33,7 +34,8 @@
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} }
] ],
"expo-localization"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

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

View File

@ -1,64 +1,237 @@
import React, { useState } from "react"; import React, { useState, useEffect, useContext, useMemo } from "react";
import { ScrollView, Text, Alert, Button } from "react-native"; import { ScrollView, Alert, useColorScheme, Appearance } from "react-native";
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";
import ChipsSelector from "@/components/ChipsSelector"; 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 { saveState, loadState } from "@/util/CalculatorState";
import {
savePersistentState,
loadPersistentState,
} from "@/util/PersistentState";
import styles, { COLORS } from "@/styles/styles";
import Section from "@/containers/Section";
import AppContext from "@/util/context";
import i18n from "@/i18n/i18n";
import { Picker, PickerItem } from "@/containers/Picker";
import { ItemValue } from "@react-native-picker/picker/typings/Picker";
const IndexScreen = () => { const IndexScreen: React.FC = () => {
const [playerCount, setPlayerCount] = useState(2); const [playerCount, setPlayerCount] = useState(2);
const [buyInAmount, setBuyInAmount] = useState<number | null>(null); const [buyInAmount, setBuyInAmount] = useState<number>(20);
const [numberOfChips, setNumberOfChips] = useState<number>(5); const [numberOfChips, setNumberOfChips] = useState<number>(5);
const [totalChipsCount, setTotalChipsCount] = useState<number[]>([]); const [totalChipsCount, setTotalChipsCount] = useState<number[]>([]);
const [selectedCurrency, setSelectedCurrency] = useState<string>("$");
const handleSave = () => { const [selectedLanguage, setSelectedLanguage] = useState<string>("en");
if (buyInAmount === null) { const colorScheme = useColorScheme();
Alert.alert("Error", "Please select a valid buy-in amount"); const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
} else { const colors = useMemo(
Alert.alert( () => (darkMode ? COLORS.DARK : COLORS.LIGHT),
"Success", [darkMode]
`Buy-in amount set to ${buyInAmount} for ${playerCount} players`
); );
const context = useContext(AppContext);
const isSettingsVisible = useMemo(() => context.showSettings, [context]);
useEffect(() => {
const loadPersistentData = async () => {
try {
const savedState = await loadPersistentState();
if (savedState) {
setPlayerCount(savedState.playerCount || 2);
setBuyInAmount(savedState.buyInAmount || 20);
setNumberOfChips(savedState.numberOfChips || 5);
setTotalChipsCount(savedState.totalChipsCount || []);
setSelectedCurrency(savedState.selectedCurrency || "$");
}
} catch (error) {
console.error("Error loading persistent state:", error);
}
};
loadPersistentData();
}, []);
const handleSave = async (slot: "SLOT1" | "SLOT2") => {
if (buyInAmount === null) {
Alert.alert(i18n.t("error"), i18n.t("please_select_valid_buyin"));
return;
}
const state = {
playerCount,
buyInAmount,
numberOfChips,
totalChipsCount,
selectedCurrency,
};
await saveState(slot, state);
await savePersistentState(state);
Alert.alert(i18n.t("success"), i18n.t("state_saved", { slot }));
};
const handleLoad = async (slot: "SLOT1" | "SLOT2") => {
const loadedState = await loadState(slot);
if (loadedState) {
setPlayerCount(loadedState.playerCount);
setBuyInAmount(loadedState.buyInAmount ?? 20);
setNumberOfChips(loadedState.numberOfChips);
setTotalChipsCount(loadedState.totalChipsCount);
setSelectedCurrency(loadedState.selectedCurrency || "$");
await savePersistentState(loadedState);
Alert.alert(i18n.t("success"), i18n.t("state_loaded_from", { slot }));
} else {
Alert.alert(i18n.t("info"), i18n.t("no_saved_state_found"));
} }
}; };
// Update chip count based on detection or manual edit const handleLanguageChange = (language: ItemValue, _: any) => {
const updateChipCount = (chipData: { [color: string]: number }) => { setSelectedLanguage(language.toString());
// Convert the chip data from the API response or manual edit to a count array i18n.changeLanguage(language.toString());
const chipCountArray = Object.entries(chipData).map(
([color, count]) => count
);
setTotalChipsCount(chipCountArray); // Update the parent component's state
}; };
return ( return (
<ScrollView contentContainerStyle={{ padding: 20, flexGrow: 1 }}> <ScrollView
<Text style={{ fontSize: 24, marginBottom: 30, marginTop: 50 }}> style={styles.scrollView}
Poker Chip Helper contentContainerStyle={styles.scrollViewContent}
</Text> >
{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
title={i18n.t("select_language")}
iconName={"language"}
orientation="row"
>
<Picker
selectedValue={selectedLanguage}
onValueChange={handleLanguageChange}
>
<PickerItem label={i18n.t("english")} value="en" />
<PickerItem label={i18n.t("spanish")} value="es" />
</Picker>
</Section>
<Section
title={i18n.t("select_currency")}
iconName={"attach-money"}
orientation="row"
>
<CurrencySelector
selectedCurrency={selectedCurrency}
setSelectedCurrency={setSelectedCurrency}
/>
</Section>
</>
)}
<Section
title={i18n.t("select_number_of_players")}
iconName={"people"}
orientation="row"
contentStyle={{ justifyContent: "center", gap: 30 }}
>
<PlayerSelector <PlayerSelector
playerCount={playerCount} playerCount={playerCount}
setPlayerCount={setPlayerCount} setPlayerCount={setPlayerCount}
/> />
<BuyInSelector setBuyInAmount={setBuyInAmount} /> </Section>
<ChipDetection updateChipCount={updateChipCount} />
<Section
title={i18n.t("select_buyin_amount")}
iconName={"monetization-on"}
>
<BuyInSelector
selectedCurrency={selectedCurrency}
setBuyInAmount={setBuyInAmount}
/>
</Section>
<Section
title={i18n.t("automatic_chip_detection")}
iconName={"camera-alt"}
>
<ChipDetection
updateChipCount={(chipData) => {
const chipCountArray = Object.values(chipData);
setTotalChipsCount(chipCountArray);
setNumberOfChips(chipCountArray.length);
}}
/>
</Section>
<Section
title={i18n.t("manual_chip_adjustment")}
iconName={"account-balance"}
orientation="row"
contentStyle={{ alignItems: "center" }}
>
<ChipsSelector <ChipsSelector
totalChipsCount={totalChipsCount} totalChipsCount={totalChipsCount}
setTotalChipsCount={setTotalChipsCount} setTotalChipsCount={setTotalChipsCount}
numberOfChips={numberOfChips} numberOfChips={numberOfChips}
setNumberOfChips={setNumberOfChips} setNumberOfChips={setNumberOfChips}
/> />
</Section>
<Section
title={i18n.t("distribution_and_denomination")}
iconName={"currency-exchange"}
>
<ChipDistributionSummary <ChipDistributionSummary
playerCount={playerCount} playerCount={playerCount}
buyInAmount={buyInAmount} buyInAmount={buyInAmount}
totalChipsCount={totalChipsCount} totalChipsCount={totalChipsCount}
selectedCurrency={selectedCurrency}
/>
</Section>
<Section
title={i18n.t("save_and_load")}
iconName={"save"}
orientation="row"
>
<>
<Button
title={i18n.t("save_slot_1")}
onPress={() => handleSave("SLOT1")}
disabled={buyInAmount === null}
size="small"
/> />
<Button <Button
title="Save" title={i18n.t("save_slot_2")}
onPress={handleSave} onPress={() => handleSave("SLOT2")}
disabled={buyInAmount === null} disabled={buyInAmount === null}
size="small"
/> />
<Button
title={i18n.t("load_slot_1")}
onPress={() => handleLoad("SLOT1")}
size="small"
/>
<Button
title={i18n.t("load_slot_2")}
onPress={() => handleLoad("SLOT2")}
size="small"
/>
</>
</Section>
</ScrollView> </ScrollView>
); );
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,15 +0,0 @@
module.exports = {
presets: ["babel-preset-expo", "@babel/preset-typescript"],
plugins: [
[
"module:react-native-dotenv",
{
moduleName: "@env",
path: ".env",
safe: true,
allowUndefined: false,
},
],
"react-native-reanimated/plugin",
],
};

View File

@ -1,33 +1,47 @@
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { import { View, Text, TextInput, useColorScheme } from "react-native";
View, import styles, { COLORS } from "@/styles/styles";
Text, import Button from "@/containers/Button";
TextInput, import i18n from "@/i18n/i18n";
TouchableOpacity,
StyleSheet,
} from "react-native";
import { MaterialIcons } from "@expo/vector-icons";
interface BuyInSelectorProps { interface BuyInSelectorProps {
setBuyInAmount: React.Dispatch<React.SetStateAction<number | null>>; setBuyInAmount: React.Dispatch<React.SetStateAction<number>>;
selectedCurrency: string;
} }
const defaultBuyInOptions = [10, 25, 50]; const defaultBuyInOptions = [10, 25, 50];
const MIN = 1;
const MAX = 200;
const BuyInSelector: React.FC<BuyInSelectorProps> = ({ setBuyInAmount }) => { 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> = ({
setBuyInAmount,
selectedCurrency,
}) => {
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 {
setCustomAmount(""); setCustomAmount("");
setBuyInAmountState(null); setBuyInAmountState(25);
setBuyInAmount(null); setBuyInAmount(25);
} }
}; };
@ -38,88 +52,35 @@ const BuyInSelector: React.FC<BuyInSelectorProps> = ({ setBuyInAmount }) => {
}; };
return ( return (
<View style={styles.container}> <>
<View style={styles.header}> <View style={{ ...styles.container, flexDirection: "row" }}>
<MaterialIcons name="monetization-on" size={30} color="green" />
<Text style={styles.title}>Select Buy-in Amount:</Text>
</View>
<View style={styles.optionsContainer}>
{defaultBuyInOptions.map((amount) => ( {defaultBuyInOptions.map((amount) => (
<TouchableOpacity <Button
key={amount} key={amount}
style={[
styles.buyInButton,
buyInAmount === amount ? styles.selectedButton : null,
]}
onPress={() => handleBuyInSelection(amount)} onPress={() => handleBuyInSelection(amount)}
> title={`${selectedCurrency} ${amount}`}
<Text style={styles.buttonText}>{amount}</Text> />
</TouchableOpacity>
))} ))}
</View> </View>
<Text style={styles.orText}>Or enter a custom amount:</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="Enter custom buy-in" placeholder={`${i18n.t("custom_buy_in")} ${MIN} - ${MAX}`}
keyboardType="numeric" keyboardType="numeric"
/> />
<Text style={styles.selectionText}> <Text style={[styles.h2, { color: colors.TEXT }]}>
Selected Buy-in: {buyInAmount !== null ? buyInAmount : "None"} {`${i18n.t("selected_buy_in")} `}
{buyInAmount !== null
? `${selectedCurrency} ${buyInAmount}`
: i18n.t("none")}
</Text> </Text>
</View> </>
); );
}; };
const styles = StyleSheet.create({
container: {
padding: 20,
},
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 10,
},
title: {
fontSize: 22,
marginLeft: 10,
},
optionsContainer: {
flexDirection: "row",
justifyContent: "space-around",
marginVertical: 10,
},
buyInButton: {
backgroundColor: "#ddd",
padding: 10,
borderRadius: 5,
},
selectedButton: {
backgroundColor: "#4caf50",
},
buttonText: {
fontSize: 16,
},
orText: {
marginTop: 10,
textAlign: "center",
},
input: {
borderWidth: 1,
borderColor: "#ccc",
padding: 8,
marginVertical: 10,
borderRadius: 5,
},
selectionText: {
marginTop: 15,
fontSize: 16,
fontWeight: "bold",
},
});
export default BuyInSelector; export default BuyInSelector;

View File

@ -1,20 +1,22 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { import { Image, ActivityIndicator, Text, View } from "react-native";
View, import Button from "@/containers/Button";
Button,
Image,
ActivityIndicator,
Text,
ScrollView,
} from "react-native";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { API_KEY, MODEL_NAME } from "@env"; import i18n from "@/i18n/i18n";
const ChipDetection = ({ updateChipCount }) => { const ChipDetection = ({
const [imageUri, setImageUri] = useState(null); updateChipCount,
}: {
updateChipCount: (chipData: Record<string, number>) => void;
}) => {
const [imageUri, setImageUri] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const [lastDetectedChips, setLastDetectedChips] = useState({}); const [lastDetectedChips, setLastDetectedChips] = useState<
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();
@ -28,16 +30,16 @@ const ChipDetection = ({ updateChipCount }) => {
quality: 1, quality: 1,
}); });
if (!result.canceled) { if (!result.canceled && result.assets && result.assets.length > 0) {
setImageUri(result.assets[0].uri); setImageUri(result.assets[0].uri);
await processImage(result.assets[0].base64); await processImage(result.assets[0].base64 as string);
} }
}; };
const takePhoto = async () => { const takePhoto = async () => {
const hasPermission = await requestCameraPermissions(); const hasPermission = await requestCameraPermissions();
if (!hasPermission) { if (!hasPermission) {
setError("Camera permission is required to take a photo."); setError(i18n.t("camera_permission_required"));
return; return;
} }
@ -46,27 +48,25 @@ const ChipDetection = ({ updateChipCount }) => {
quality: 1, quality: 1,
}); });
if (!result.canceled) { if (!result.canceled && result.assets && result.assets.length > 0) {
setImageUri(result.assets[0].uri); setImageUri(result.assets[0].uri);
await processImage(result.assets[0].base64); await processImage(result.assets[0].base64 as string);
} }
}; };
const processImage = async (base64Image) => { const processImage = async (base64Image: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch( const response = await fetch(process.env.EXPO_PUBLIC_API_URL as string, {
"https://api.openai.com/v1/chat/completions",
{
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${API_KEY}`, Authorization: `Bearer ${process.env.EXPO_PUBLIC_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: MODEL_NAME, model: process.env.EXPO_PUBLIC_MODEL_NAME,
messages: [ messages: [
{ {
role: "system", role: "system",
@ -89,47 +89,65 @@ const ChipDetection = ({ updateChipCount }) => {
], ],
max_tokens: 1000, max_tokens: 1000,
}), }),
} });
);
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("Invalid response from API."); 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 = JSON.parse(cleanJSON); const filteredData = Object.entries(parsedData)
.filter(([color]) => chipColors.includes(color))
// Filter out colors with a count of 0 .sort(
const filteredData = Object.fromEntries( ([colorA], [colorB]) =>
Object.entries(parsedData).filter(([_, count]) => count > 0) chipColors.indexOf(colorA) - chipColors.indexOf(colorB)
)
.reduce(
(acc, [color, count]) => {
acc[color] = count;
return acc;
},
{} as Record<string, number>
); );
setLastDetectedChips(filteredData); // Store detected chip counts setLastDetectedChips(filteredData);
updateChipCount(filteredData); updateChipCount(filteredData);
} catch (error) { } catch (error) {
setError("Failed to analyze the image."); setError(i18n.t("failed_to_analyze_image"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<ScrollView contentContainerStyle={{ padding: 20, alignItems: "center" }}> <View>
<Button title="Pick an Image" onPress={pickImage} /> <View
<Button title="Take a Photo" onPress={takePhoto} /> style={{
flexDirection: "row",
justifyContent: "space-evenly",
marginBottom: 10,
}}
>
<Button title={i18n.t("pick_an_image")} onPress={pickImage} />
<Button title={i18n.t("take_a_photo")} onPress={takePhoto} />
</View>
{imageUri && ( {imageUri && (
<Image <Image
source={{ uri: imageUri }} source={{ uri: imageUri }}
style={{ width: 300, height: 300, marginTop: 10 }} style={{ width: 300, height: 300, marginTop: 10 }}
/> />
)} )}
{loading && <ActivityIndicator size="large" color="blue" />} {loading && <ActivityIndicator size="large" color="blue" />}
{error && <Text style={{ color: "red", marginTop: 10 }}>{error}</Text>} {error && <Text style={{ color: "red", marginTop: 10 }}>{error}</Text>}
</ScrollView> </View>
); );
}; };

View File

@ -1,89 +1,200 @@
import React, { 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 styles, { COLORS } from "@/styles/styles";
import { MaterialIcons } from "@expo/vector-icons";
interface ChipDistributionSummaryProps { interface ChipDistributionSummaryProps {
playerCount: number; playerCount: number;
buyInAmount: number | null; buyInAmount: number;
totalChipsCount: number[]; totalChipsCount: number[];
colors?: ColorValue[]; colors?: ColorValue[];
selectedCurrency: string;
} }
const MAX_CHIPS = 500; const reverseFib: number[] = [8, 5, 3, 2, 1];
const ChipDistributionSummary = ({ const ChipDistributionSummary = ({
playerCount, playerCount,
buyInAmount, buyInAmount,
totalChipsCount, totalChipsCount,
colors = ["white", "red", "green", "blue", "black"], colors = ["white", "red", "green", "blue", "black"],
selectedCurrency = "$",
}: ChipDistributionSummaryProps) => { }: ChipDistributionSummaryProps) => {
const [chipDistribution, setChipDistribution] = useState<number[]>([]); const validDenominations: validDenomination[] = [
0.05, 0.1, 0.25, 1, 5, 10, 20, 50, 100,
];
const [denominations, setDenominations] = useState<validDenomination[]>([]);
const [distributions, setDistributions] = useState<number[]>([]);
const showAlert = () => {
Alert.alert(i18n.t("warning"), i18n.t("chip_value_warn"));
};
type validDenomination =
| 0.05
| 0.1
| 0.25
| 0.5
| 1
| 2
| 2.5
| 5
| 10
| 20
| 25
| 50
| 100;
const findFloorDenomination = (target: number): validDenomination => {
let current: validDenomination = validDenominations[0];
validDenominations.forEach((value, _) => {
if (value < target) current = value;
});
return current;
};
const round = useCallback((num: number) => Math.round(num * 100) / 100, []);
// Bound for the value of the highest chip
// This is somewhat arbitray and imperfect, but 1/3 to 1/5 is reasonable depending on the number of colors.
// 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;
}
return max;
}, [totalChipsCount, buyInAmount]);
const potValue = useMemo(
() => buyInAmount * playerCount,
[buyInAmount, playerCount]
);
// The total value of all chips distributed to a single player. Ideally should be equal to buyInAmount
const totalValue = useMemo(() => {
let value = 0;
for (let i = 0; i < distributions.length; i++) {
value += distributions[i] * denominations[i];
}
return value;
}, [distributions, denominations]);
// Maximum quantity of each chip color which may be distributed to a single player before running out
const maxPossibleDistribution = useMemo(
() => totalChipsCount.map((v) => Math.floor(v / playerCount)),
[totalChipsCount, playerCount]
);
// Dynamically set denominations and distributions from changing inputs
useEffect(() => { useEffect(() => {
if (buyInAmount !== null && playerCount > 0) { let testDenomination: validDenomination[] = [];
let totalChips = totalChipsCount.reduce((sum, count) => sum + count, 0); const totalNumColors = totalChipsCount.length;
if (totalChips > MAX_CHIPS) { // Start with max denominations, then push on the next adjacent lower denomination
const scaleFactor = MAX_CHIPS / totalChips; testDenomination.push(maxDenomination);
totalChipsCount = totalChipsCount.map((count) => let currentDenominationIndex: number =
Math.floor(count * scaleFactor) validDenominations.indexOf(maxDenomination);
); for (
totalChips = MAX_CHIPS; let i = totalNumColors - 2;
i >= 0 && currentDenominationIndex > 0;
i = i - 1
) {
currentDenominationIndex -= 1;
const currentDemoniation = validDenominations[currentDenominationIndex];
testDenomination.push(currentDemoniation);
} }
const distribution = totalChipsCount.map((chipCount) => testDenomination.reverse();
Math.floor(chipCount / playerCount) let numColors = testDenomination.length;
);
setChipDistribution(distribution); const testDistribution: number[] = [];
} else { for (let i = 0; i < numColors; ++i) {
setChipDistribution([]); testDistribution.push(0);
} }
}, [buyInAmount, playerCount, totalChipsCount]);
const hasValidDistribution = useMemo( // Distribute the chips using the test denomination with a reverse fibbonaci preference
() => // Not optimal, nor correct under all inputs but works for most inputs
buyInAmount !== null && playerCount > 0 && chipDistribution.length > 0, // Algorithm could be improved with more complexity and optimization (re-tries, redenominating, etc.)
[buyInAmount, playerCount, chipDistribution] let remainingValue = buyInAmount;
); let stop = false;
while (remainingValue > 0 && !stop) {
let distributed = false;
for (let i = numColors - 1; i >= 0; i = i - 1) {
for (
let j = reverseFib[i];
j > 0 &&
remainingValue >= testDenomination[i] &&
testDistribution[i] < maxPossibleDistribution[i];
j = j - 1
) {
testDistribution[i] = testDistribution[i] + 1;
remainingValue = round(remainingValue - testDenomination[i]);
distributed = true;
}
}
if (distributed == false) {
stop = true;
}
}
setDenominations(testDenomination);
setDistributions(testDistribution);
}, [totalChipsCount, maxDenomination, buyInAmount, playerCount]);
return ( return (
<>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>Chip Distribution Summary:</Text> {distributions.map((distribution, index) => {
{hasValidDistribution ? ( return (
chipDistribution.map((count, index) => ( distribution > 0 && (
<Text key={index} style={[styles.chipText, { color: colors[index] }]}> <View style={{ flexDirection: "row" }} key={index}>
{`${colors[index]?.toString().toUpperCase()} Chips: ${count} per player`} <Text
style={{
...styles.h2,
fontWeight: "bold",
color: colors[index],
...(colors[index] === "white" && styles.shadow),
}}
>
{`${distribution} ${i18n.t("chips")}: ${selectedCurrency}${denominations[index]} ${i18n.t("each")}`}
</Text> </Text>
)) </View>
) : ( )
<Text style={styles.noDataText}> );
No valid distribution calculated yet. })}
</View>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<View style={[styles.container, { flexDirection: "row", gap: 1 }]}>
<Text style={styles.p}>
{i18n.t("total_value")}: {selectedCurrency} {round(totalValue)}{" "}
</Text> </Text>
{round(totalValue) !== buyInAmount && (
<MaterialIcons
name="warning"
size={20}
color={COLORS.WARNING}
onPress={showAlert}
/>
)} )}
</View> </View>
<Text style={styles.p}>
{selectedCurrency} {potValue} {i18n.t("pot")}
</Text>
</View>
</>
); );
}; };
const styles = StyleSheet.create({
container: {
marginTop: 20,
padding: 15,
backgroundColor: "#F8F9FA",
borderRadius: 10,
},
title: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 10,
},
chipText: {
fontSize: 16,
marginVertical: 2,
},
noDataText: {
fontSize: 16,
color: "gray",
},
});
export default ChipDistributionSummary; export default ChipDistributionSummary;

View File

@ -4,11 +4,17 @@ import {
Text, Text,
TextInput, TextInput,
StyleSheet, StyleSheet,
Button,
ColorValue, ColorValue,
Modal, Modal,
TouchableOpacity,
} from "react-native"; } from "react-native";
import Button from "@/containers/Button";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import styles from "@/styles/styles";
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,
@ -24,23 +30,35 @@ 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]);
return ( return (
<Modal <Modal
visible={showModal[0]} visible={showModal[0]}
onRequestClose={() => setShowModal([false, color])} onRequestClose={() => setShowModal([false, color])}
style={styles.modal}
presentationStyle="fullScreen"
animationType="slide"
> >
{value !== undefined && ( {value !== undefined && (
<> <>
<Text>Number of {showModal[1]?.toString()} chips</Text> <Text style={styles.h2}>
{i18n.t("number_of_chips", {
color: showModal[1]?.toString(),
})}{" "}
</Text>
<TextInput <TextInput
style={{ color: showModal[1] }} style={{
...styles.input,
color: showModal[1],
...(shadow ? styles.shadow : {}),
}}
keyboardType="numeric" keyboardType="numeric"
value={value.toString()} value={value.toString()}
onChangeText={(v) => { onChangeText={(v) => {
@ -52,7 +70,7 @@ const ChipInputModal = ({
</> </>
)} )}
<Button <Button
title="Accept" title={i18n.t("accept")}
onPress={() => { onPress={() => {
update(showModal[1], Number.isNaN(value) ? 0 : value); update(showModal[1], Number.isNaN(value) ? 0 : value);
setShowModal([false, color]); setShowModal([false, color]);
@ -71,14 +89,25 @@ const Chip = ({
count: number; count: number;
setShowModal: React.Dispatch<React.SetStateAction<[boolean, ColorValue]>>; setShowModal: React.Dispatch<React.SetStateAction<[boolean, ColorValue]>>;
}) => { }) => {
const shadow = useMemo(() => color === "white", [color]);
return ( return (
<TouchableOpacity
onPress={() => setShowModal([true, color])}
style={{ alignItems: "center" }}
>
<MaterialCommunityIcons
name="poker-chip"
size={24}
color={color}
style={shadow ? styles.shadow : {}}
/>
<Text <Text
key={color.toString()} key={color.toString()}
onPress={() => setShowModal([true, color])} style={[{ color: color }, styles.h2, shadow ? styles.shadow : {}]}
style={[{ color: color }, styles.chip]}
> >
{count} {count}
</Text> </Text>
</TouchableOpacity>
); );
}; };
@ -97,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 parents state.
const update = useCallback( const update = useCallback(
(color: ColorValue, count: number) => { (color: ColorValue, count: number) => {
const newTotalChipsCount = totalChipsCount.slice(); const newTotalChipsCount = totalChipsCount.slice();
@ -110,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);
} }
} }
@ -134,35 +161,33 @@ const ChipsSelector = ({
return ( return (
<> <>
<View style={styles.container}>
<Text style={styles.title}>Chips you have</Text>
<View style={styles.chipContainer}>
{colorsUsed.map((color) => (
<Chip
key={color.toString()}
color={color}
count={totalChipsCount[colors.indexOf(color)] ?? 0}
setShowModal={setShowModal}
/>
))}
</View>
<View style={styles.buttonContainer}>
<Button <Button
title="-" title="-"
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" }]}>
{colorsUsed.map((color) => {
const chipCount = totalChipsCount[colors.indexOf(color)] ?? 0;
return (
<Chip
key={color.toString()}
color={color}
count={chipCount}
setShowModal={setShowModal}
/>
);
})}
</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}
/> />
</View>
</View>
<ChipInputModal <ChipInputModal
showModal={showModal} showModal={showModal}
@ -174,34 +199,4 @@ const ChipsSelector = ({
); );
}; };
const styles = 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

@ -0,0 +1,30 @@
import React from "react";
import i18n from "@/i18n/i18n";
import { Picker, PickerItem } from "@/containers/Picker";
interface CurrencySelectorProps {
selectedCurrency: string;
setSelectedCurrency: React.Dispatch<React.SetStateAction<string>>;
}
const CurrencySelector: React.FC<CurrencySelectorProps> = ({
selectedCurrency,
setSelectedCurrency,
}) => {
return (
<>
<Picker
selectedValue={selectedCurrency}
onValueChange={(itemValue) => setSelectedCurrency(itemValue.toString())}
testID="currency-picker" // ✅ Add testID here
>
<PickerItem label={i18n.t("usd")} value="$" />
<PickerItem label={i18n.t("euro")} value="€" />
<PickerItem label={i18n.t("pound")} value="£" />
<PickerItem label={i18n.t("inr")} value="₹" />
</Picker>
</>
);
};
export default CurrencySelector;

View File

@ -1,64 +1,49 @@
import React from "react"; import React, { useMemo } from "react";
import { View, Text, Button, Image, StyleSheet } from "react-native"; import { View, Text, useColorScheme } from "react-native";
import Button from "@/containers/Button";
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 MAX = 8;
const PlayerSelector: React.FC<PlayerSelectorProps> = ({ const PlayerSelector: React.FC<PlayerSelectorProps> = ({
playerCount, playerCount,
setPlayerCount, setPlayerCount,
}) => { }) => {
const increasePlayers = () => { const increasePlayers = () => {
if (playerCount < 8) setPlayerCount(playerCount + 1); if (playerCount < MAX) setPlayerCount(playerCount + 1);
}; };
const decreasePlayers = () => { const decreasePlayers = () => {
if (playerCount > 2) 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={styles.container}> <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<View style={styles.header}> <Button
<Image title="-"
source={{ onPress={decreasePlayers}
uri: "https://static.thenounproject.com/png/3890959-200.png", disabled={playerCount <= MIN}
}} />
style={styles.icon} <Text style={[styles.h1, { color: colors.TEXT }]}>{playerCount}</Text>
<Button
title="+"
onPress={increasePlayers}
disabled={playerCount >= MAX}
/> />
<Text style={styles.title}>Select Number of Players:</Text>
</View>
<Text style={styles.playerCount}>{playerCount}</Text>
<View style={{ flexDirection: "row" }}>
<Button title="-" onPress={decreasePlayers} />
<Button title="+" onPress={increasePlayers} />
</View>
</View> </View>
); );
}; };
const styles = StyleSheet.create({
container: {
padding: 20,
},
header: {
flexDirection: "row",
alignItems: "center",
},
title: {
fontSize: 18,
marginLeft: 10, // Spacing between icon and text
},
icon: {
width: 48, // Increased size
height: 48, // Increased size
},
playerCount: {
fontSize: 24,
marginVertical: 10,
},
});
export default PlayerSelector; export default PlayerSelector;

View File

@ -2,8 +2,8 @@ import React from "react";
import { fireEvent, render } from "@testing-library/react-native"; import { fireEvent, render } from "@testing-library/react-native";
import BuyInSelector from "@/components/BuyInSelector"; import BuyInSelector from "@/components/BuyInSelector";
// Mocking vector icons to prevent errors
jest.mock("@expo/vector-icons", () => { jest.mock("@expo/vector-icons", () => {
const React = require("react");
const { Text } = require("react-native"); const { Text } = require("react-native");
return { return {
MaterialIcons: () => <Text>MaterialIcons</Text>, MaterialIcons: () => <Text>MaterialIcons</Text>,
@ -11,78 +11,144 @@ jest.mock("@expo/vector-icons", () => {
}); });
describe("BuyInSelector Component", () => { describe("BuyInSelector Component", () => {
it("renders the buy-in options and input correctly", () => { let setBuyInAmount: jest.Mock;
const setBuyInAmount = jest.fn();
const { getByText, getByPlaceholderText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} />
);
expect(getByText("Select Buy-in Amount:")).toBeTruthy(); // Render the component and return query methods
expect(getByText("10")).toBeTruthy(); const renderComponent = (selectedCurrency = "$") => {
expect(getByText("25")).toBeTruthy(); const utils = render(
expect(getByText("50")).toBeTruthy(); <BuyInSelector
expect(getByPlaceholderText("Enter custom buy-in")).toBeTruthy(); setBuyInAmount={setBuyInAmount}
selectedCurrency={selectedCurrency}
/>
);
return {
...utils,
getByText: utils.getByText,
getByPlaceholderText: utils.getByPlaceholderText,
queryByText: utils.queryByText,
};
};
beforeEach(() => {
setBuyInAmount = jest.fn();
});
it("renders the buy-in options and input correctly", () => {
const { getByText, getByPlaceholderText, queryByText } = renderComponent();
expect(getByText("$ 10")).toBeTruthy();
expect(getByText("$ 25")).toBeTruthy();
expect(getByText("$ 50")).toBeTruthy();
expect(
getByPlaceholderText("Or, enter a custom amount: 1 - 200")
).toBeTruthy();
expect(queryByText(/Selected Buy-in:.*None/i)).toBeTruthy();
}); });
it("sets a predefined buy-in amount correctly", () => { it("sets a predefined buy-in amount correctly", () => {
const setBuyInAmount = jest.fn(); const { getByText } = renderComponent();
const { getByText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} />
);
fireEvent.press(getByText("25"));
fireEvent.press(getByText("$ 25"));
expect(setBuyInAmount).toHaveBeenCalledWith(25); expect(setBuyInAmount).toHaveBeenCalledWith(25);
}); });
it("sets a custom buy-in amount correctly", () => { it("sets a custom buy-in amount correctly", () => {
const setBuyInAmount = jest.fn(); const { getByPlaceholderText } = renderComponent();
const { getByPlaceholderText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} /> fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"100"
); );
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "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 setBuyInAmount = jest.fn(); const { getByPlaceholderText } = renderComponent();
const { getByPlaceholderText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} /> fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"-10"
); );
expect(setBuyInAmount).toHaveBeenCalledWith(1); // Min value
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-10"); fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
expect(setBuyInAmount).toHaveBeenCalledWith(null); "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 setBuyInAmount = jest.fn(); const { getByPlaceholderText, getByText } = renderComponent();
const { getByText, getByPlaceholderText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} /> fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"100"
); );
fireEvent.press(getByText("$ 50"));
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "100");
fireEvent.press(getByText("50"));
expect(setBuyInAmount).toHaveBeenCalledWith(50); expect(setBuyInAmount).toHaveBeenCalledWith(50);
}); });
it("handles valid and invalid input for custom amount correctly", () => { it("handles valid and invalid input for custom amount correctly", () => {
const setBuyInAmount = jest.fn(); const { getByPlaceholderText } = renderComponent();
const { getByPlaceholderText } = render(
<BuyInSelector setBuyInAmount={setBuyInAmount} />
);
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(null); getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"-5"
);
expect(setBuyInAmount).toHaveBeenCalledWith(1);
fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); fireEvent.changeText(
expect(setBuyInAmount).toHaveBeenCalledWith(null); getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"abc"
);
expect(setBuyInAmount).toHaveBeenCalledWith(25);
});
it("triggers state update every time a buy-in option is clicked, even if it's the same", () => {
const { getByText } = renderComponent();
fireEvent.press(getByText("$ 25"));
fireEvent.press(getByText("$ 25"));
expect(setBuyInAmount).toHaveBeenCalledTimes(2);
});
it("resets to default buy-in when custom input is cleared", () => {
const { getByPlaceholderText } = renderComponent();
const input = getByPlaceholderText("Or, enter a custom amount: 1 - 200");
fireEvent.changeText(input, "75");
expect(setBuyInAmount).toHaveBeenCalledWith(75);
fireEvent.changeText(input, "");
expect(setBuyInAmount).toHaveBeenCalledWith(25);
});
it("updates state correctly when selecting predefined buy-in after entering a custom amount", () => {
const { getByPlaceholderText, getByText } = renderComponent();
fireEvent.changeText(
getByPlaceholderText("Or, enter a custom amount: 1 - 200"),
"200"
);
expect(setBuyInAmount).toHaveBeenCalledWith(200);
fireEvent.press(getByText("$ 10"));
expect(setBuyInAmount).toHaveBeenCalledWith(10);
});
it("displays selected currency correctly", () => {
const { getByText, queryByText } = renderComponent("€");
expect(getByText("€ 10")).toBeTruthy();
expect(getByText("€ 25")).toBeTruthy();
expect(getByText("€ 50")).toBeTruthy();
expect(queryByText(/Selected Buy-in:.*None/i)).toBeTruthy();
}); });
}); });

View File

@ -17,11 +17,134 @@ jest.mock("expo-image-picker", () => ({
describe("ChipDetection", () => { describe("ChipDetection", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
global.fetch = jest.fn(() => jest.spyOn(global, "fetch").mockImplementation(() =>
Promise.resolve({ Promise.resolve(
ok: true, new Response(
json: () => JSON.stringify({
Promise.resolve({ choices: [
{
message: {
content: JSON.stringify({
red: 5,
green: 3,
blue: 0,
}),
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
)
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders correctly", () => {
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
expect(getByText(/pick an image/i)).toBeTruthy();
expect(getByText(/take a photo/i)).toBeTruthy();
});
it("picks an image from the library", async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText(/pick an image/i));
await waitFor(() => expect(mockUpdateChipCount).toHaveBeenCalled());
});
it("takes a photo with the camera", async () => {
(
ImagePicker.requestCameraPermissionsAsync as jest.Mock
).mockResolvedValueOnce({
granted: true,
});
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-camera-uri", base64: "test-camera-base64" }],
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText(/take a photo/i));
await waitFor(() =>
expect(mockUpdateChipCount).toHaveBeenCalledWith({
red: 5,
green: 3,
blue: 0,
})
);
});
it("handles camera permission denied", async () => {
(
ImagePicker.requestCameraPermissionsAsync as jest.Mock
).mockResolvedValueOnce({
granted: false,
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText(/take a photo/i));
await waitFor(() =>
expect(
getByText(/camera permission is required to take a photo/i)
).toBeTruthy()
);
});
it("displays error message on image processing failure", async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
jest.spyOn(global, "fetch").mockImplementationOnce(() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [] }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
)
);
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText(/pick an image/i));
await waitFor(() =>
expect(getByText(/failed to analyze the image/i)).toBeTruthy()
);
});
it("handles valid API response correctly", async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
jest.spyOn(global, "fetch").mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({
choices: [ choices: [
{ {
message: { message: {
@ -30,120 +153,21 @@ describe("ChipDetection", () => {
}, },
], ],
}), }),
}) { status: 200, headers: { "Content-Type": "application/json" } }
); )
}); )
it("renders correctly", () => {
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
expect(getByText("Pick an Image")).toBeTruthy();
expect(getByText("Take a Photo")).toBeTruthy();
});
it("picks an image from the library", async () => {
ImagePicker.launchImageLibraryAsync.mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText("Pick an Image"));
await waitFor(() => expect(mockUpdateChipCount).toHaveBeenCalled());
});
it("takes a photo with the camera", async () => {
ImagePicker.requestCameraPermissionsAsync.mockResolvedValueOnce({
granted: true,
});
ImagePicker.launchCameraAsync.mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-camera-uri", base64: "test-camera-base64" }],
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText("Take a Photo"));
await waitFor(() => expect(mockUpdateChipCount).toHaveBeenCalled());
});
it("handles camera permission denied", async () => {
ImagePicker.requestCameraPermissionsAsync.mockResolvedValueOnce({
granted: false,
});
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText("Take a Photo"));
await waitFor(() =>
expect(
getByText("Camera permission is required to take a photo.")
).toBeTruthy()
);
});
it("displays error message on image processing failure", async () => {
ImagePicker.launchImageLibraryAsync.mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
global.fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ choices: [] }),
})
); );
const { getByText } = render( const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} /> <ChipDetection updateChipCount={mockUpdateChipCount} />
); );
fireEvent.press(getByText("Pick an Image")); fireEvent.press(getByText(/pick an image/i));
await waitFor(() =>
expect(getByText("Failed to analyze the image.")).toBeTruthy()
);
});
it("handles valid API response correctly", async () => {
ImagePicker.launchImageLibraryAsync.mockResolvedValueOnce({
canceled: false,
assets: [{ uri: "test-uri", base64: "test-base64" }],
});
global.fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify({ red: 5, green: 3 }),
},
},
],
}),
})
);
const { getByText } = render(
<ChipDetection updateChipCount={mockUpdateChipCount} />
);
fireEvent.press(getByText("Pick an Image"));
await waitFor(() => await waitFor(() =>
expect(mockUpdateChipCount).toHaveBeenCalledWith({ expect(mockUpdateChipCount).toHaveBeenCalledWith({
red: 5, red: 5,
green: 3, green: 3,
blue: 0,
}) })
); );
}); });

View File

@ -1,65 +1,59 @@
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, 200, 300, 400, 500]; const totalChipsCount = [100, 80, 60, 40, 20];
const colors = ["WHITE", "RED", "GREEN", "BLUE", "BLACK"]; const buyInAmount = 20;
const expectedDistribution = [16, 12, 8, 6, 2];
// Update this to match the actual component's chip distribution logic const expectedDenominations = [0.05, 0.1, 0.25, 1, 5];
const expectedDistribution = [8, 16, 25, 33, 41]; // Adjust based on actual component calculations
const { getByText } = render( const { getByText } = render(
<ChipDistributionSummary <ChipDistributionSummary
playerCount={playerCount} playerCount={playerCount}
buyInAmount={100} buyInAmount={buyInAmount}
totalChipsCount={totalChipsCount} totalChipsCount={totalChipsCount}
selectedCurrency={"$"}
/> />
); );
expect(getByText("Chip Distribution Summary:")).toBeTruthy();
expectedDistribution.forEach((count, index) => { expectedDistribution.forEach((count, index) => {
expect(getByText(new RegExp(`${colors[index]} Chips: ${count} per player`, "i"))).toBeTruthy(); const regex = new RegExp(
}); `^${count}\\s+chips:\\s+\\$${expectedDenominations[index]}\\s+Each$`,
}); "i"
test("renders fallback message when no valid distribution", () => {
const { getByText } = render(
<ChipDistributionSummary playerCount={0} buyInAmount={null} totalChipsCount={[]} />
); );
expect(getByText("No valid distribution calculated yet.")).toBeTruthy(); expect(getByText(regex)).toBeTruthy();
});
}); });
test("scales down chips if exceeding MAX_CHIPS", () => { test("renders warning message when needed", async () => {
const playerCount = 2;
let totalChipsCount = [300, 400, 500, 600, 700];
const MAX_CHIPS = 500;
const totalChips = totalChipsCount.reduce((sum, count) => sum + count, 0);
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 to match actual component calculations
const colors = ["WHITE", "RED", "GREEN", "BLUE", "BLACK"];
const { getByText } = render( const { getByText } = render(
<ChipDistributionSummary <ChipDistributionSummary
playerCount={playerCount} playerCount={6}
buyInAmount={100} buyInAmount={25}
totalChipsCount={totalChipsCount} selectedCurrency={"$"}
totalChipsCount={[100, 50]}
/> />
); );
const warning = getByText("TestIcon");
expect(warning).toBeTruthy();
expect(getByText("Chip Distribution Summary:")).toBeTruthy(); jest.spyOn(Alert, "alert");
fireEvent.press(warning);
expectedDistribution.forEach((count, index) => { expect(Alert.alert).toHaveBeenCalledWith(
expect(getByText(new RegExp(`${colors[index]} Chips: ${count} per player`, "i"))).toBeTruthy(); "Warning",
`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`
);
}); });
}); });
});

View File

@ -3,13 +3,19 @@ import {
userEvent, userEvent,
render, render,
screen, screen,
waitForElementToBeRemoved,
fireEvent, fireEvent,
} from "@testing-library/react-native"; } from "@testing-library/react-native";
import ChipsSelector from "@/components/ChipsSelector"; import ChipsSelector from "@/components/ChipsSelector";
const TOTAL_CHIPS_COUNT = [100, 80, 60, 40, 20]; const TOTAL_CHIPS_COUNT = [100, 80, 60, 40, 20];
jest.mock("@expo/vector-icons", () => {
const { Text } = require("react-native");
return {
MaterialCommunityIcons: () => <Text>TestIcon</Text>,
};
});
const mocktTotalChipsCount = jest.fn(); const mocktTotalChipsCount = jest.fn();
const mockSetNumberOfChips = jest.fn(); const mockSetNumberOfChips = jest.fn();
@ -48,7 +54,8 @@ describe("tests for ChipsSelector", () => {
const green = screen.getByText("60"); const green = screen.getByText("60");
expect(green).toHaveStyle({ color: "green" }); expect(green).toHaveStyle({ color: "green" });
userEvent.press(green); fireEvent.press(green);
const modalLabel = await screen.findByText(/number of green chips/i); const modalLabel = await screen.findByText(/number of green chips/i);
expect(modalLabel).toBeDefined(); expect(modalLabel).toBeDefined();
@ -73,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

@ -0,0 +1,48 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import CurrencySelector from "@/components/CurrencySelector";
describe("CurrencySelector Component", () => {
const mockSetSelectedCurrency = jest.fn();
test("renders CurrencySelector component correctly", () => {
const { getByText, getByTestId } = render(
<CurrencySelector
selectedCurrency="$"
setSelectedCurrency={mockSetSelectedCurrency}
/>
);
expect(getByTestId("currency-picker")).toBeTruthy(); // Check Picker exists
});
test("calls setSelectedCurrency when a new currency is selected", () => {
const { getByTestId } = render(
<CurrencySelector
selectedCurrency="$"
setSelectedCurrency={mockSetSelectedCurrency}
/>
);
const picker = getByTestId("currency-picker"); // Get Picker
fireEvent(picker, "onValueChange", "€"); // Simulate selecting Euro (€)
expect(mockSetSelectedCurrency).toHaveBeenCalledWith("€");
});
test("updates selected currency when Picker value changes", () => {
const { getByTestId } = render(
<CurrencySelector
selectedCurrency="€"
setSelectedCurrency={mockSetSelectedCurrency}
/>
);
const picker = getByTestId("currency-picker");
fireEvent(picker, "onValueChange", "$"); // Simulate selecting USD ($)
expect(mockSetSelectedCurrency).toHaveBeenCalledWith("$");
});
});

View File

@ -9,7 +9,6 @@ describe("PlayerSelector Component", () => {
<PlayerSelector playerCount={4} setPlayerCount={setPlayerCount} /> <PlayerSelector playerCount={4} setPlayerCount={setPlayerCount} />
); );
expect(getByText("Select Number of Players:")).toBeTruthy();
expect(getByText("4")).toBeTruthy(); expect(getByText("4")).toBeTruthy();
expect(getByRole("button", { name: "-" })).toBeTruthy(); expect(getByRole("button", { name: "-" })).toBeTruthy();
expect(getByRole("button", { name: "+" })).toBeTruthy(); expect(getByRole("button", { name: "+" })).toBeTruthy();

82
containers/Button.tsx Normal file
View File

@ -0,0 +1,82 @@
import { COLORS } from "@/styles/styles";
import React, { useMemo } from "react";
import {
Text,
TouchableOpacity,
StyleSheet,
useColorScheme,
} from "react-native";
interface ButtonProps {
title: string;
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>
);
};
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>
);
};

83
containers/Section.tsx Normal file
View File

@ -0,0 +1,83 @@
import { View, Text, StyleSheet, useColorScheme } from "react-native";
import React, { useMemo } from "react";
import { MaterialIcons } from "@expo/vector-icons";
import globalStyles, { COLORS } from "@/styles/styles";
const titleCase = (input: string) =>
input
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
// Wrapper container for styling purposes
const Section = ({
title,
iconName,
children,
orientation = "column",
contentStyle = {},
}: {
title: string;
iconName: string | any;
children: React.JSX.Element;
orientation?: "row" | "column";
contentStyle?: object;
}) => {
const colorScheme = useColorScheme();
const darkMode = useMemo(() => colorScheme === "dark", [colorScheme]);
const colors = useMemo(
() => (darkMode ? COLORS.DARK : COLORS.LIGHT),
[darkMode]
);
return (
<View style={styles.container}>
<View style={styles.header}>
<MaterialIcons
style={styles.icon}
name={iconName}
size={30}
color={colors.TEXT}
/>
<Text style={[styles.title, { color: colors.TEXT }]}>
{titleCase(title)}
</Text>
</View>
<View
style={{
...styles.content,
...contentStyle,
flexDirection: orientation,
}}
>
{children}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 20,
display: "flex",
alignContent: "center",
justifyContent: "center",
},
header: {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 5,
marginBottom: 10,
},
icon: {},
title: {
...globalStyles.h1,
},
content: {
display: "flex",
justifyContent: "space-evenly",
gap: 5,
},
});
export default Section;

View File

@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react-native";
import Section from "../Section";
import { Text } from "react-native";
import React from "react";
jest.mock("@expo/vector-icons", () => {
const { Text } = require("react-native");
return {
MaterialIcons: () => <Text testID={"test-icon"}>TestIcon</Text>,
};
});
const TITLE = "Select the weather";
const rend = () =>
render(
<Section title={TITLE} iconName={"test-icon"}>
<Text>child</Text>
</Section>
);
describe("tests for the Section HOC Component", () => {
it("everything expected appears", () => {
rend();
const title = screen.getByText(/select the weather/i);
expect(title).toBeTruthy();
const child = screen.getByText("child");
expect(child).toBeTruthy();
const icon = screen.getByTestId("test-icon");
expect(icon).toBeTruthy();
});
});

54
i18n/en.json Normal file
View File

@ -0,0 +1,54 @@
{
"translation": {
"poker_chips_helper": "Poker Chips Helper",
"select_currency": "Select Currency",
"usd": "USD ($)",
"euro": "Euro (€)",
"pound": "Pound (£)",
"inr": "INR (₹)",
"select_number_of_players": "Select the Number of Players:",
"select_buyin_amount": "Select Buy-in Amount:",
"custom_buy_in": "Or, enter a custom amount:",
"selected_buy_in": "Selected Buy-in:",
"none": "None",
"pick_an_image": "Pick an image",
"take_a_photo": "Take a photo",
"chips": "chips",
"number_of_chips": "Number of {{color}} chips",
"accept": "Accept",
"distribution_and_denomination": "Distribution & Denomination",
"pot": "Pot",
"total_value": "Total Value",
"each": "Each",
"select_language": "Select Language",
"english": "English",
"spanish": "Spanish",
"settings": "Settings",
"camera_permission_required": "Camera permission is required to take a photo.",
"how_many_chips_by_color": "How many poker chips are there for each color? Return structured JSON.",
"invalid_response": "Invalid response from API.",
"failed_to_analyze_image": "Failed to analyze the image.",
"state_saved_successfully": "State saved successfully",
"state_saved": "State saved to {{slot}}",
"state_save_failed": "Failed to save state",
"success": "Success",
"error": "Error",
"failed_to_save_state": "Failed to save state.",
"state_loaded_from": "State loaded from",
"info": "Info",
"warning": "Warning",
"no_saved_state_found": "No saved state found.",
"automatic_chip_detection": "Automatic Chip Detection",
"manual_chip_adjustment": "Manual Chip Adjustment",
"save_and_load": "Save & Load",
"save_slot_1": "Save\nSlot 1",
"save_slot_2": "Save\nSlot 2",
"load_slot_1": "Load\nSlot 1",
"load_slot_2": "Load\nSlot 2",
"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"
}
}

55
i18n/es.json Normal file
View File

@ -0,0 +1,55 @@
{
"translation": {
"poker_chips_helper": "Ayudante de Fichas de Póker",
"select_currency": "Seleccionar moneda",
"usd": "USD ($)",
"euro": "Euro (€)",
"pound": "Libra (£)",
"inr": "INR (₹)",
"select_number_of_players": "Seleccionar número de jugadores:",
"select_buyin_amount": "Seleccionar cantidad de buy-in:",
"custom_buy_in": "O, ingresa una cantidad personalizada:",
"selected_buy_in": "Buy-in seleccionado:",
"none": "Ninguno",
"pick_an_image": "Elige una imagen",
"take_a_photo": "Tomar una foto",
"chips": "fichas",
"number_of_chips": "Número de {{color}} fichas",
"accept": "Aceptar",
"distribution_and_denomination": "Distribución y Denominación",
"pot": "Olla",
"total_value": "Valor total",
"each": "Cada",
"select_language": "Seleccionar idioma",
"english": "Inglés",
"spanish": "Español",
"settings": "Configuraciones",
"camera_permission_required": "Se requiere permiso de cámara para tomar una foto.",
"how_many_chips_by_color": "¿Cuántas fichas de póker hay de cada color? Devuelve JSON estructurado.",
"invalid_response": "Respuesta no válida de la API.",
"failed_to_analyze_image": "No se pudo analizar la imagen",
"state_saved_successfully": "Estado guardado con éxito",
"state_saved": "Estado guardado en {{slot}}",
"state_save_failed": "Error al guardar el estado",
"success": "Éxito",
"state_saved_to": "Estado guardado en",
"error": "Error",
"failed_to_save_state": "No se pudo guardar el estado.",
"state_loaded_from": "Estado cargado desde",
"info": "Información",
"warning": "Advertencia",
"no_saved_state_found": "No se encontró estado guardado.",
"automatic_chip_detection": "Detección automática de fichas",
"manual_chip_adjustment": "Ajuste manual de fichas",
"save_and_load": "Guardar & Cargar",
"save_slot_1": "Guardar\nSlot 1",
"save_slot_2": "Guardar\nSlot 2",
"load_slot_1": "Cargar\nSlot 1",
"load_slot_2": "Cargar\nSlot 2",
"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"
}
}

22
i18n/i18n.ts Normal file
View File

@ -0,0 +1,22 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import * as Localization from "expo-localization";
import en from "./en.json";
import es from "./es.json";
const resources = {
en,
es,
};
const detectedLanguage = Localization.locale.split("-")[0] || "en";
i18n.use(initReactI18next).init({
resources,
lng: detectedLanguage,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

1972
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,8 @@
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test": "jest --watchAll", "test": "jest --watchAll",
"lint": "expo lint" "lint": "expo lint"
@ -14,57 +14,62 @@
"jest": { "jest": {
"preset": "jest-expo" "preset": "jest-expo"
}, },
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.26.0", "@expo/vector-icons": "14.0.4",
"@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "^2.1.1",
"@react-navigation/bottom-tabs": "^7.2.0", "@react-native-picker/picker": "^2.11.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/bottom-tabs": "7.2.0",
"expo": "~52.0.31", "@react-navigation/native": "7.0.14",
"expo-blur": "~14.0.3", "expo": "^52.0.37",
"expo-constants": "~17.0.5", "expo-blur": "14.0.3",
"expo-file-system": "~18.0.11", "expo-constants": "17.0.7",
"expo-font": "~13.0.3", "expo-file-system": "18.0.11",
"expo-haptics": "~14.0.1", "expo-font": "13.0.4",
"expo-image-picker": "~16.0.6", "expo-haptics": "14.0.1",
"expo-linking": "~7.0.5", "expo-image-picker": "16.0.6",
"expo-router": "~4.0.17", "expo-linking": "7.0.5",
"expo-splash-screen": "~0.29.21", "expo-localization": "~16.0.1",
"expo-status-bar": "~2.0.1", "expo-router": "4.0.17",
"expo-symbols": "~0.2.2", "expo-splash-screen": "0.29.22",
"expo-system-ui": "~4.0.8", "expo-status-bar": "2.0.1",
"expo-web-browser": "~14.0.2", "expo-symbols": "0.2.2",
"metro-react-native-babel-preset": "^0.77.0", "expo-system-ui": "4.0.8",
"expo-web-browser": "14.0.2",
"i18next": "^24.2.2",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "^0.76.7", "react-i18next": "^15.4.1",
"react-native-dotenv": "^3.4.11", "react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "2.20.2",
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "3.16.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"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.9", "@babel/core": "7.26.9",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "10.4.0",
"@testing-library/jest-native": "^5.4.3", "@testing-library/jest-native": "5.4.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "16.2.0",
"@testing-library/react-native": "^13.0.1", "@testing-library/react-native": "13.0.1",
"@types/jest": "^29.5.12", "@types/jest": "29.5.14",
"@types/react": "~18.3.12", "@types/react": "18.3.12",
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "18.3.0",
"eslint": "^9.20.0", "eslint": "9.21.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "10.0.1",
"eslint-plugin-prettier": "^5.2.3", "eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "7.37.4",
"eslint-plugin-react-native": "^5.0.0", "eslint-plugin-react-native": "5.0.0",
"jest": "^29.7.0", "jest": "29.7.0",
"jest-expo": "~52.0.3", "jest-expo": "52.0.5",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "3.0.3",
"prettier": "^3.4.2", "prettier": "3.5.2",
"react-test-renderer": "18.3.1", "react-test-renderer": "18.3.1",
"typescript": "^5.7.3" "typescript": "5.7.3"
}, },
"private": true "private": true
} }

72
styles/styles.ts Normal file
View File

@ -0,0 +1,72 @@
import { StyleSheet } from "react-native";
export const COLORS = {
WARNING: "#c79c28",
LIGHT: {
TEXT: "black",
PRIMARY: "lightgrey",
SECONDARY: "azure",
BACKGROUND: "ghostwhite",
DISABLED: "gray",
},
DARK: {
TEXT: "white",
PRIMARY: "black",
SECONDARY: "teal",
BACKGROUND: "dimgrey",
DISABLED: "gray",
},
};
const GlobalStyles = StyleSheet.create({
scrollView: {},
scrollViewContent: {
padding: 15,
},
container: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: 10,
},
h1: { fontSize: 19, fontWeight: "bold" },
h2: { fontSize: 18, fontWeight: "normal" },
p: {
fontSize: 16,
color: "#333",
},
input: {
borderWidth: 1,
borderColor: "#ccc",
padding: 8,
marginVertical: 10,
borderRadius: 5,
},
modal: {},
button: {
backgroundColor: "#007bff",
padding: 10,
borderRadius: 5,
},
shadow: {
textShadowColor: "black",
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
picker: {
borderRadius: 10,
height: 55,
width: 150,
},
pickerItem: {},
pickerWrapper: {
borderRadius: 10,
overflow: "hidden",
},
});
export default GlobalStyles;

37
util/CalculatorState.ts Normal file
View File

@ -0,0 +1,37 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEYS = {
SLOT1: "@poker_state_slot1",
SLOT2: "@poker_state_slot2",
};
export interface PokerState {
playerCount: number;
buyInAmount: number | null;
numberOfChips: number;
totalChipsCount: number[];
selectedCurrency: string;
}
export const saveState = async (
slot: keyof typeof STORAGE_KEYS,
state: PokerState
) => {
try {
await AsyncStorage.setItem(STORAGE_KEYS[slot], JSON.stringify(state));
return { success: true, message: `State saved to ${slot}` };
} catch (error) {
return { success: false, message: "Failed to save state" };
}
};
export const loadState = async (
slot: keyof typeof STORAGE_KEYS
): Promise<PokerState | null> => {
try {
const storedState = await AsyncStorage.getItem(STORAGE_KEYS[slot]);
return storedState ? JSON.parse(storedState) : null;
} catch (error) {
return null;
}
};

39
util/PersistentState.ts Normal file
View File

@ -0,0 +1,39 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "@poker_calculator_state";
export interface PokerState {
playerCount: number;
buyInAmount: number | null;
numberOfChips: number;
totalChipsCount: number[];
selectedCurrency: string;
}
const DEFAULT_STATE: PokerState = {
playerCount: 0,
buyInAmount: null,
numberOfChips: 0,
totalChipsCount: [],
selectedCurrency: "$",
};
// 🔹 Save state with currency
export const savePersistentState = async (state: PokerState) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return { success: true, message: "State saved successfully" };
} catch (error) {
return { success: false, message: "Failed to save state" };
}
};
// 🔹 Load state with currency
export const loadPersistentState = async (): Promise<PokerState> => {
try {
const storedState = await AsyncStorage.getItem(STORAGE_KEY);
return storedState ? JSON.parse(storedState) : DEFAULT_STATE; // Ensure default values
} catch (error) {
return DEFAULT_STATE;
}
};

13
util/context.ts Normal file
View File

@ -0,0 +1,13 @@
import React, { createContext } from "react";
export interface IAppContext {
showSettings: boolean;
}
const defaultContext: IAppContext = {
showSettings: false,
};
const AppContext = createContext<IAppContext>(defaultContext);
export default AppContext;

View File

@ -0,0 +1,86 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { saveState, loadState, PokerState } from "@/util/CalculatorState";
// Mock AsyncStorage
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);
describe("CalculatorState.ts", () => {
const mockState: PokerState = {
playerCount: 4,
buyInAmount: 50,
numberOfChips: 5,
totalChipsCount: [100, 200, 150, 50, 75],
selectedCurrency: "$", // Including selectedCurrency in mockState
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should save state successfully to SLOT1", async () => {
await saveState("SLOT1", mockState);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
"@poker_state_slot1",
JSON.stringify(mockState)
);
});
it("should save state successfully to SLOT2", async () => {
await saveState("SLOT2", mockState);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
"@poker_state_slot2",
JSON.stringify(mockState)
);
});
it("should fail to save state if an error occurs", async () => {
jest
.spyOn(AsyncStorage, "setItem")
.mockRejectedValueOnce(new Error("Failed to save"));
const result = await saveState("SLOT1", mockState);
expect(result.success).toBe(false);
expect(result.message).toBe("Failed to save state");
});
it("should load state successfully from SLOT1", async () => {
await AsyncStorage.setItem("@poker_state_slot1", JSON.stringify(mockState));
const result = await loadState("SLOT1");
expect(result).toEqual(mockState);
});
it("should load state successfully from SLOT2", async () => {
await AsyncStorage.setItem("@poker_state_slot2", JSON.stringify(mockState));
const result = await loadState("SLOT2");
expect(result).toEqual(mockState);
});
it("should return null if no state is found in SLOT1", async () => {
jest.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null);
const result = await loadState("SLOT1");
expect(result).toBeNull();
});
it("should return null if no state is found in SLOT2", async () => {
jest.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null);
const result = await loadState("SLOT2");
expect(result).toBeNull();
});
it("should return null if an error occurs while loading state from SLOT1", async () => {
jest
.spyOn(AsyncStorage, "getItem")
.mockRejectedValueOnce(new Error("Failed to load"));
const result = await loadState("SLOT1");
expect(result).toBeNull();
});
it("should return null if an error occurs while loading state from SLOT2", async () => {
jest
.spyOn(AsyncStorage, "getItem")
.mockRejectedValueOnce(new Error("Failed to load"));
const result = await loadState("SLOT2");
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,101 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
savePersistentState,
loadPersistentState,
PokerState,
} from "@/util/PersistentState";
jest.mock("@react-native-async-storage/async-storage", () => ({
setItem: jest.fn(),
getItem: jest.fn(),
}));
describe("PersistentState.ts", () => {
const mockState: PokerState = {
playerCount: 4,
buyInAmount: 50,
numberOfChips: 5,
totalChipsCount: [100, 200, 150, 50, 75],
selectedCurrency: "$", // Including selectedCurrency in mockState
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should save state successfully", async () => {
// Mocking AsyncStorage.setItem to resolve successfully
(AsyncStorage.setItem as jest.Mock).mockResolvedValueOnce(undefined);
const result = await savePersistentState(mockState);
// Check that the success flag is true and message is as expected
expect(result.success).toBe(true);
expect(result.message).toBe("State saved successfully");
// Check that AsyncStorage.setItem was called with the correct parameters
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
"@poker_calculator_state",
JSON.stringify(mockState)
);
});
it("should fail to save state if an error occurs", async () => {
// Mocking AsyncStorage.setItem to reject with an error
(AsyncStorage.setItem as jest.Mock).mockRejectedValueOnce(
new Error("Failed to save")
);
const result = await savePersistentState(mockState);
// Check that the success flag is false and message is as expected
expect(result.success).toBe(false);
expect(result.message).toBe("Failed to save state");
});
it("should load state successfully", async () => {
// Mocking AsyncStorage.getItem to resolve with the mockState
(AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(
JSON.stringify(mockState)
);
const result = await loadPersistentState();
// Check that the loaded state matches the mockState
expect(result).toEqual(mockState);
});
it("should load default state if no saved state is found", async () => {
// Mocking AsyncStorage.getItem to return null (no saved state)
(AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(null);
const result = await loadPersistentState();
// Check that the default state is returned
expect(result).toEqual({
playerCount: 0,
buyInAmount: null,
numberOfChips: 0,
totalChipsCount: [],
selectedCurrency: "$",
});
});
it("should return default state if an error occurs while loading", async () => {
// Mocking AsyncStorage.getItem to reject with an error
(AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(
new Error("Failed to load")
);
const result = await loadPersistentState();
// Check that the default state is returned on error
expect(result).toEqual({
playerCount: 0,
buyInAmount: null,
numberOfChips: 0,
totalChipsCount: [],
selectedCurrency: "$",
});
});
});