15 Commits

52 changed files with 3411 additions and 21587 deletions

6
.gitignore vendored
View File

@ -17,4 +17,8 @@ web-build/
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
# @end expo-cliandroid
android
builds
.env
PliWould.keystore

122
README.md
View File

@ -1,50 +1,98 @@
# Welcome to your Expo app 👋
# PliWould - Measure And Price Sheet Good Merchandise
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
![PlyWould's ugly logo](./assets/images/pli-would-512.png)
Working behind the register at Habitat for Humanity ReStore I found sheet goods often came in partials.
As a cashier, I would often have to determine the correct price for merchandise based on the area or length
of products.
Not being skilled in math, I found this process extremely stressful and would often get stuck and call over
a manager to assist. Instead, I took it upon myself to find a solution.
Hence, I created PliWould, an app that determines the cost of a product based on its dimensions.
By default my store's prices are in the database.
However, the prices are editable and you can add or remove
products as needed.
## Usage
![App Overview](./doc/images/screenshots/index.png)
### ![Scale Tab](./doc/images/icons/scale.png) Measure Tab
This tab is used to determine the price based on measurements.
Select a product from a list of products.
![Plywood sheet selected](./doc/images/screenshots/plywood-sheet-4-by-8-inches.png)
There are 2 different types of products:
1. Length prodcuts
2. Area products.
Typically length products have a Square button that measures per length,
but some do not, so I included them.
Area products on the Square console are "partials," so they are listed in the product list.
Select one you wish to price.
Automatically the measurements for the "base measurement" will be filled in.
Using a tape measure, measure the sheet's length and width. You can either put in
inches or feet (switch using the `in`/`ft` button selector).
If the product is damaged, use the slider to select the amount of damage
![Plywood sheet selected, with 25% damage](./doc/images/screenshots/plywood-sheet-4-by-8-feet-25-damage.png)
If you select a length product, only the length field will be present. Proceed as you
would with area products.
![Length product selected](./doc/images/screenshots/house-siding-length-input-feet.png)
### ![Product Editor Tab](./doc/images/icons/list.png) Product Editor (WIP)
In the product editor, you can add or remove products as needed.
You can even edit or add attributes.
Note that the `name` attribute is highly recommended as it's the name of the product.
Otherwise, it will display as `Product <UUID>`.
# Development Docs
This is an [Expo](https://expo.dev) project.
The `develop` branch is used to develop features until it's ready to be merged
into main.
## Get started
1. Clone the repository.
```
$ git clone https://gittea.dev/srcrr/PliWould
```
2. Install eas-cli **globally**
```
$ npm i -g eas-cli
```
1. Install dependencies
```bash
npm install
pnpm install
```
2. Start the app
```bash
npx expo start
pnpx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

View File

@ -2,11 +2,20 @@ import { Product } from "@/lib/product";
export const products = [
// Sheet goods
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }),
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }),
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }),
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }),
// Beams and trim
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }),
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }),
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "MDF" }),
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Pegboard" }),
new Product(5, {l: 3, w : 5, u: "ft"}, { name: "Cement" }),
// trim
new Product(1, {l: 0.50, u : "ft"}, { name: "trim <= 3 inches" }),
new Product(1, {l: 0.75, u : "ft"}, { name: "trim > 3 inches" }),
// siding
new Product(1, {l: 1, u: "ft"}, {name: "house siding"}),
new Product(1, {l: 1, u: "ft"}, {name: "metal / shelf bars"}),
new Product(0.5, {l: 1, u: "ft"}, {name: "gutter spouts"}),
];

View File

@ -1,12 +1,12 @@
{
"expo": {
"name": "PliWould",
"slug": "PliWould",
"slug": "pliwould",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"scheme": "pliwould",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
@ -19,7 +19,8 @@
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"package": "tech.damngood.pliwould",
},
"web": {
"bundler": "metro",
@ -31,6 +32,15 @@
],
"experiments": {
"typedRoutes": true
}
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "113390d8-ca95-42f1-bd03-577b83487f7c"
}
},
"owner": "damngoodtech"
}
}

View File

@ -1,14 +1,20 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Provider } from 'react-redux';
import { products as fixtures } from "@/__fixtures__/initialProducts"
import { setupStore } from '../store';
export default function TabLayout() {
const colorScheme = useColorScheme();
const store = setupStore({
products: fixtures.map(p => p.asObject),
units: "ft",
});
return (
<Provider store={store}>
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
@ -17,21 +23,22 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: 'Conversion',
title: 'Measure',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
<TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
),
}}
/>
<Tabs.Screen
name="product-editor"
options={{
title: 'Products',
title: 'Edit Products',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
),
}}
/>
</Tabs>
</Provider>
);
}

View File

@ -1,50 +1,10 @@
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MeasurementInput } from '@/components/LengthInput';
import { setupStore, useAppDispatch } from '../store';
import { selectProducts } from '@/features/product/productSlice';
import { Product } from '@/lib/product';
import { ProductTile } from '@/components/ProductTile';
import { Measure, area, length } from 'enheter';
export default function HomeScreen() {
const products = useAppDispatch(selectProducts);
function calculatePrice() {
}
const selectProduct = (product : Product) => {
}
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { SafeAreaView, Text, View } from 'react-native';
export default function Convert () {
return (
<SafeAreaView>
<MeasurementInput onMeasurementSet={calculatePrice} />
{products.map((product) => {
<ProductTile product={product} onProductSelected={selectProduct} />
})}
</SafeAreaView>
);
<View>
<ProductCalculatorSelector />
</View>
)
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: 'absolute',
},
});

View File

@ -1,12 +1,7 @@
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MeasurementInput } from '@/components/LengthInput';
import { setupStore, useAppDispatch } from '../store';
import { selectProducts } from '@/features/product/productSlice';
import { Product } from '@/lib/product';
import { ProductTile } from '@/components/ProductTyle';
import { Measure, area, length } from 'enheter';
import { ProductEditor } from '@/components/ProductEditor';
export default function HomeScreen() {

View File

@ -1,11 +1,13 @@
import 'react-native-reanimated';
import 'react-native-gesture-handler';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Text, View } from 'react-native';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@ -26,6 +28,12 @@ export default function RootLayout() {
return null;
}
// return (
// <View>
// <Text>Hello World!</Text>
// </View>
// )
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>

23
app/app.tsx Normal file
View File

@ -0,0 +1,23 @@
import { LinkingOptions, NavigationContainer } from "@react-navigation/native";
import { Text, View } from "react-native";
import * as Linking from "expo-linking";
import Root from "./+html";
const prefix = Linking.createURL("/");
const linking: LinkingOptions<any> = {
prefixes: [
"myapp://", prefix,
],
config: {
screens: {}
},
};
export default function App() {
return (
<NavigationContainer linking={linking} fallback={<Text>Loading...</Text>}>
<Root />
</NavigationContainer>
);
}

View File

@ -3,14 +3,18 @@ import { configureStore } from '@reduxjs/toolkit';
import { rememberReducer, rememberEnhancer } from 'redux-remember';
import reducers from "@/features/product/productSlice"
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, } from "@/lib/product";
import { ProductData } from "@/lib/dimensions_t";
import {Length} from "convert"
const rememberedKeys = ['products'];
const rootReducer = reducers;
const isBrowser = (typeof window !== "undefined");
export function setupStore(preloadedState = {
products: [] as Product[],
products: [] as ProductData[],
units: "ft" as Length,
}) {
return configureStore({
reducer: rememberReducer(reducers),
@ -20,8 +24,8 @@ export function setupStore(preloadedState = {
AsyncStorage,
rememberedKeys,
{
persistWholeStore: true,
}
persistWholeStore: false,
},
)
),
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

68
components/AreaInput.tsx Normal file
View File

@ -0,0 +1,68 @@
import { MeasurementInput } from "./MeasurementInput";
import { area_t, dimensions_t } from "@/lib/dimensions_t";
import { Length } from "convert";
import { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
export type AreaInputProps = {
onMeasurementSet?: (area : dimensions_t) => any,
defaultValue?: area_t,
lengthLabel?: string,
widthLabel?: string,
units?: Length,
}
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue, units} : AreaInputProps) {
defaultValue = defaultValue || {l: 0, w: 0, u: "ft"}
units = units || "ft"
const [area, setArea] = useState(defaultValue)
function doOnLengthSet(measurement : dimensions_t) {
setArea({
...area,
l: measurement.l
});
onMeasurementSet && onMeasurementSet({
...area,
l: measurement.l
});
}
function doOnWidthSet(measurement : dimensions_t) {
setArea({
...area,
w: measurement.l
});
onMeasurementSet && onMeasurementSet({
...area,
w: measurement.l
});
}
return (
<View style={styles.areaInputWrapper}>
<MeasurementInput
defaultValue={{l: area.l, u: area.u}}
onValueSet={doOnLengthSet}
label={lengthLabel}
units={units}
/>
<Text style={{fontSize: 30,}} > x </Text>
<MeasurementInput
defaultValue={{l: area.w, u: area.u}}
onValueSet={doOnWidthSet}
label={widthLabel}
units={units}
/>
</View>
)
}
const styles = StyleSheet.create({
areaInputWrapper: {
flexDirection: "row",
verticalAlign: "middle",
}
})

53
components/AreaRugTag.tsx Normal file
View File

@ -0,0 +1,53 @@
import { area_t } from "@/lib/dimensions";
import convert, { Area, Length } from "convert";
import dayjs, { Dayjs } from "dayjs";
import { StyleSheet, Text, View } from "react-native";
export type AreaRugTagProps = {
dimensions: area_t,
price_per_area: {
price: number,
per: {
n: number,
u: Area,
}
},
date?: Dayjs
currencySymbol?: string
};
export const AreaRugTag = (props: AreaRugTagProps) => {
const date = props.date || dayjs();
const square = props.dimensions.l * props.dimensions.w;
const areaUnits = `square ${props.dimensions.u}`;
const square2 = convert(square, areaUnits as Area).to(props.price_per_area.per.u)
const price = (square2 / props.price_per_area.per.n) * props.price_per_area.price;
const sPrice = price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const currencySymbol = props.currencySymbol || "$";
return (
<View style={styles.component}>
<Text aria-label="area rug dimensions" style={styles.dimensions}>{props.dimensions.l} x {props.dimensions.w}</Text>
<Text aria-label="area rug price" style={styles.price}>{currencySymbol} {sPrice}</Text>
<Text aria-label="area rug date" style={styles.date}>{date.format("YYYY/MM/DD")}</Text>
<Text aria-label="this week's color" style={styles.tagColor}>[Curent Tag Color]</Text>
</View>
)
};
const styles = StyleSheet.create({
component: {
paddingVertical: 100,
flex: 1,
},
dimensions: {
},
price: {
},
date: {
},
tagColor: {
},
})

View File

@ -1,86 +0,0 @@
import { Measure, Unit, length as en_length, area as en_area } from "enheter";
import { useState } from "react";
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export type t_length_unit = "foot" | "inch"
export type mode = "length" | "area"
export type LengthInputProps = {
onMeasurementSet?: (length: Measure<"length" | "area">) => any,
isArea?: boolean,
}
export function MeasurementInput(props: LengthInputProps) {
const [length, setLength] = useState(null as null | number);
const [width, setWidth] = useState(null as null | number);
const [unit, setUnit] = useState("foot" as t_length_unit);
function doSetLength(text: string) {
const value = parseFloat(text);
setLength(value);
if (!props.isArea) {
const len = en_length(unit, value)
props.onMeasurementSet && props.onMeasurementSet(len)
} else {
const en_unit = unit == "foot" ? "squareFoot" : "squareInch"
const ar = en_area(en_unit, value);
props.onMeasurementSet && props.onMeasurementSet(ar);
}
}
function doSetWidth(text: string) {
const value = parseFloat(text);
setLength(value);
const len = en_length(unit, value)
props.onMeasurementSet && props.onMeasurementSet(len)
}
return (
<SafeAreaView>
<View style={styles.inputRow}>
<TextInput
keyboardType="number-pad"
onTouchEnd={() => setLength(null)}
value={length?.toString() || ""}
onChangeText={doSetLength}
style={styles.textInput}
/>
{props.isArea &&
(<TextInput
keyboardType="number-pad"
onTouchEnd={() => setWidth(null)}
value={length?.toString() || ""}
onChangeText={doSetWidth}
style={styles.textInput}
/>)
}
<Text style={styles.valueHint}>{unit == "foot" ? "ft" : "in"}</Text>
<Button
title="Ft"
onPress={() => setUnit("foot")}
color={unit === "foot" ? "blue" : "gray"}
/>
<Button
title="In"
onPress={() => setUnit("inch")}
color={unit === "inch" ? "blue" : "gray"}
/>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
textInput: {
flexGrow: 1,
},
inputRow: {
flexDirection: "row"
},
valueHint: {
color: "grey",
margin: 5,
}
})

View File

@ -0,0 +1,67 @@
import { dimensions_t, length_t } from "@/lib/dimensions_t";
import { Length } from "convert";
import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";
export type t_length_unit = "foot" | "inch"
export type MeasurementInputProps = {
onValueSet?: (d: dimensions_t) => any,
defaultValue: length_t;
label?: string,
units?: Length,
}
export function MeasurementInput({onValueSet, defaultValue, label, units}: MeasurementInputProps) {
const [mValue, setMValue] = useState(defaultValue)
const defValue = Number.isNaN(defaultValue.l) ? 0 : defaultValue.l
units = units || "ft";
function doOnValueSet(value : string) {
setMValue(mValue);
const iVal = parseFloat(value) || parseInt(value);
onValueSet && onValueSet({
...defaultValue,
l: iVal,
})
}
const sDefValue = new String(defValue).valueOf()
return (
<View style={styles.inputWrapper}>
<TextInput
clearTextOnFocus={true}
defaultValue={sDefValue}
onChangeText={doOnValueSet}
inputMode='decimal'
style={styles.lengthInput}
aria-label={label || "Enter measurement"}
/>
<Text style={styles.unitHints}>{units}</Text>
</View>
)
}
const styles = StyleSheet.create({
inputWrapper: {
alignItems: "flex-start",
flexDirection: "row",
verticalAlign: "middle"
},
unitHints: {
padding: 10,
fontSize: 20,
verticalAlign: "middle",
},
lengthInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 25,
},
})

View File

@ -0,0 +1,57 @@
import { StyleSheet, Text, TextInput, View } from "react-native";
import Slider from '@react-native-community/slider';
import { useEffect, useState } from "react";
type PercentDamageProps = {
onSetPercentage: (percent: number) => any;
}
function getDamangeColor(damage : number) {
if (damage === 0) return "black";
if (damage <= 20) return "blue";
if (damage <= 50) return "orange";
return "red";
}
export default function PercentDamage({ onSetPercentage }: PercentDamageProps) {
const [damage, setDamage] = useState(0);
const [damageColor, setDamageColor] = useState("black");
function doOnChangeText(val: number) {
setDamage(val || 0);
onSetPercentage((val / 100) || 0);
setDamageColor(getDamangeColor(val || 0));
}
return (
<View style={styles.wrapper}>
<Slider
minimumValue={0}
maximumValue={100}
step={5}
onValueChange={doOnChangeText}
/>
<Text style={{ ...styles.label, color: damageColor }}> {damage}% Damage</Text>
</View>
)
}
const styles = StyleSheet.create({
wrapper: {
padding: 5,
},
input: {
flex: 1,
margin: 5,
padding: 5,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
label: {
margin: 5,
alignSelf: "center",
fontSize: 20,
fontWeight: "bold",
fontStyle: "italic",
}
})

38
components/Price.tsx Normal file
View File

@ -0,0 +1,38 @@
import { StyleSheet, Text, View } from "react-native";
export type PriceDisplayProps = {
price: number,
currency?: {
symbol: string,
}
}
export default function PriceDisplay({ price }: PriceDisplayProps) {
return (
<View style={styles.bigPriceWrapper} aria-label="calculated price">
<Text style={styles.bigPrice}>$ {price.toLocaleString(
undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}</Text>
</View>
);
}
export const styles = StyleSheet.create({
bigPriceWrapper: {
alignContent: "center",
},
bigPrice: {
alignSelf: "center",
fontSize: 40,
marginTop: 50,
marginBottom: 50,
}
});

View File

@ -4,46 +4,65 @@ import React from "react";
import { useState } from "react";
import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native";
export type ProductAttributeChangeFunc = (product_id: string, key: string, newValue: string) => any;
export type ProductAttributeDeleteFunc = (product_id: string, key: string) => any;
export type ProductAttributeChangeFunc = (key: string, newValue: string) => any;
export type ProductAttributeDeleteFunc = (key: string) => any;
export type ChangeAttributeFunction = (oldKey : string, newKey : string) => any;
export type ProductAttributeProps = { product: Product, attributeKey: string, attributeValue: string, onChange?: ProductAttributeChangeFunc, onDelete?: ProductAttributeChangeFunc, };
export type ProductAttributeProps = {
attributeKey: string,
attributeValue: string,
onChangeAttributeKey?: ChangeAttributeFunction,
onChangeAttribute?: ProductAttributeChangeFunc,
onDelete?: ProductAttributeChangeFunc,
};
export const ProductAttributeEditor = ({ product, attributeKey: key, attributeValue: value, onDelete, onChange } : ProductAttributeProps) => {
const [doEdit, setDoEdit] = useState(true);
const [newValue, setNewValue] = useState(value);
export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => {
const doChange = (e: any) => {
setNewValue(e);
onChange && onChange(product.id, key, e);
const doChangeKey = (e: any) => {
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
}
const doChangeValue = (e: any) => {
onChangeAttribute && onChangeAttribute(attributeKey, e);
}
return (
<View>
<Text>{key}</Text>
<View>
<View style={styles.productAttributeRow}>
<TextInput
defaultValue={attributeKey}
onChangeText={doChangeKey}
style={styles.value}
aria-label="Edit Key"
/>
<TextInput
defaultValue={attributeValue}
onChangeText={doChangeValue}
style={styles.value}
aria-label="Edit Value" />
<TouchableHighlight
onPress={() => setDoEdit(!doEdit)}
aria-label="Property Value"
>
{doEdit ?
(<Text>{newValue}</Text>) :
(<TextInput
value={newValue}
onChangeText={doChange}
aria-label="Edit Value" />)
}
</TouchableHighlight>
<TouchableHighlight
onPress={() => onDelete && onDelete(product.id, key, value)}
aria-label="Delete Attribute">
<Ionicons name="trash-bin-outline" />
onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
aria-label="Delete Attribute"
style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}>
<Ionicons name="trash-bin-outline" size={30} color={"white"} />
</TouchableHighlight>
</View>
</View>
)
}
const style = StyleSheet.create({
const styles = StyleSheet.create({
productAttributeRow: {
flexDirection: "row",
},
key: {
flex: 1,
},
value: {
flex: 1,
borderWidth: 1,
borderColor: "grey",
borderStyle: "solid",
padding: 10
}
});

View File

@ -0,0 +1,207 @@
import { Product } from '@/lib/product';
import { dimensions_t } from "@/lib/dimensions_t";
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import PriceDisplay from './Price';
import { AreaInput } from './AreaInput';
import { MeasurementInput } from './MeasurementInput';
import ProductList from './ProductList';
import UnitChooser from './UnitChooser';
import convert, { Length } from 'convert';
import PercentDamage from './PercentDamange';
export default function ProductCalculatorSelector() {
const [activeProduct, setActiveProduct] = useState(null as Product | null);
const [price, setPrice] = useState(0);
const [measurement, setMeasurement] = useState({ l: 0, w: 0, u: "ft" } as dimensions_t);
const [percentDamage, setPercentDamange] = useState(0.0);
useEffect(function () {
const iv = setInterval(function () {
if (!(activeProduct && measurement)) return;
setPrice(
activeProduct.priceFor(measurement, percentDamage)
)
}, 50);
return function () {
clearInterval(iv);
};
}, [activeProduct, measurement, percentDamage]);
function onMeasurementSet(dimensions: dimensions_t) {
setMeasurement(dimensions);
activeProduct && setPrice(
activeProduct.priceFor(measurement, percentDamage)
)
}
function onUnitChosen(unit: Length) {
setMeasurement({
...measurement,
u: unit,
});
}
function onSetPercentDamage(pct: number) {
setPercentDamange(pct);
}
function onProductSelected(product: Product) {
setActiveProduct(product);
setMeasurement(
{
l: convert(
product.dimensions.l,
product.dimensions.u,
).to(measurement.u),
u: measurement.u,
...(
("w" in measurement && "w" in product.dimensions) ? {
w: convert(
product.dimensions.w,
product.dimensions.u,
).to(measurement.u),
u: measurement.u,
} : {}
)
}
);
}
return (
<SafeAreaView style={styles.wrapper}>
<PriceDisplay price={price} />
<View style={styles.inputAndUnitWrapper}>
<View style={styles.inputWrapper}>
{
activeProduct ? (
"w" in activeProduct.dimensions ?
<AreaInput
defaultValue={activeProduct.dimensions}
onMeasurementSet={onMeasurementSet}
widthLabel='enter width'
lengthLabel='enter length'
units={measurement.u}
/>
:
<MeasurementInput
defaultValue={activeProduct.dimensions}
onValueSet={onMeasurementSet}
label="enter length"
units={measurement.u}
/>
) : (
<Text>Please select a product</Text>
)
}
{
activeProduct && <UnitChooser choices={["in", "ft"]} onChoicePressed={onUnitChosen} />
}
</View>
</View>
{activeProduct &&
(<View style={styles.damageWrapper}>
<PercentDamage
onSetPercentage={onSetPercentDamage}
/>
</View>)
}
<ProductList onProductSelected={onProductSelected} />
</SafeAreaView>
);
}
export const styles = StyleSheet.create({
wrapper: {
overflow: "scroll"
},
bigPriceWrapper: {
alignContent: "center",
},
bigPrice: {
alignSelf: "center",
fontSize: 40,
marginTop: 100,
marginBottom: 100,
},
inputWrapper: {
flexDirection: "row",
alignItems: "flex-start",
verticalAlign: "middle",
},
unitSelector: {
},
inputAndUnitWrapper: {
flexDirection: "row",
alignSelf: "center",
},
widthInput: {
width: 200,
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 30,
},
activeProduct: {
borderWidth: 2,
borderColor: "black",
borderStyle: "solid",
},
inactiveProduct: {
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
},
productTileTouchable: {
margin: 10,
padding: 20,
backgroundColor: "grey",
},
productTileTouchableActive: {
borderWidth: 2,
borderStyle: "solid",
borderColor: "black",
margin: 10,
padding: 20,
},
productTileText: {
textAlign: "center",
color: "white",
},
productTileTextActive: {
textAlign: "center",
color: "black",
},
productTileCover: {
padding: 4,
},
damageWrapper: {
paddingVertical: 10,
paddingHorizontal: 10,
},
});

View File

@ -1,6 +1,7 @@
import { useAppDispatch, useAppSelector } from "@/app/store"
import { deleteProduct, selectProducts, updateProduct } from "@/features/product/productSlice"
import { Product } from "@/lib/product";
import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice"
import { Id, Product } from "@/lib/product";
import { dimensions_t } from "@/lib/dimensions_t";
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
import { ProductEditorItem } from "./ProductEditorItem";
@ -13,23 +14,50 @@ export const ProductEditor = ({}) => {
dispatch(deleteProduct(product_id));
}
function onProductUpdated(product_id: string, product: Product) {
dispatch(updateProduct(product));
function onAttributeDelete(product_id: string, attribute: string) {
dispatch(deleteAttribute({product_id: product_id, attribute}));
}
function onAttributeUpdated(product_id: string, attribute: string, value: string) {
dispatch(updateAttribute({product_id, attributeKey: attribute, attributeValue: value}));
}
function onAttributeAdded(product_id: Id) {
console.log("Adding attribute to %s", product_id);
dispatch(addAttribute(product_id));
}
function onPriceUpdated(product_id: string, pricePerUnit: number) {
dispatch(updatePrice({product_id, pricePerUnit}));
}
function onAttributeKeyChanged(product_id : string, oldKey : string, newKey : string) {
dispatch(changeKey({product_id, oldKey, newKey}))
}
function onDimensionUpdated(product_id: string, dimensions: dimensions_t) {
dispatch(updateDimensions({product_id, dimensions}));
}
return (
<SafeAreaView>
<Text>Hello</Text>
<SafeAreaView style={{overflow: "scroll"}}>
<Text>Edit Products</Text>
<FlatList
data={products}
keyExtractor={(p, i) => `product-${p.id}`}
renderItem={
({item}) => {
return (
<ProductEditorItem
product={item}
onProductDeleted={onProductDeleted}
onProductUpdated={onProductUpdated}
onAttributeDeleted={onAttributeDelete}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onAttributeAdded={onAttributeAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
/>
)
}
@ -40,6 +68,10 @@ export const ProductEditor = ({}) => {
}
const styles = StyleSheet.create({
h1: {
textAlign: "center",
fontFamily: "sans-serif"
},
product: {
}

View File

@ -1,56 +1,162 @@
import { Product } from "@/lib/product"
import { Id, Product } from "@/lib/product"
import { dimensions_t } from "@/lib/dimensions_t";
import { useState } from "react"
import { FlatList, StyleSheet, Text, TouchableHighlight, View } from "react-native"
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
import { ProductAttributeEditor } from "./ProductAttributeEditor";
import React from "react";
import { Dropdown } from 'react-native-element-dropdown';
import { Ionicons } from "@expo/vector-icons";
import { Length } from "convert";
export type ProductUpdatedFunc = (product_id: string, product: Product) => any;
export type ProductDeletedFunc = (product_id: string) => any;
export type ProductAddedFunc = () => any;
export type ProductDeletedFunc = (product_id: Id) => any;
export type AttributeAddedFunc = (product_id: Id) => any;
export type AttributeKeyUpdatedFunc = (product_id: Id, oldKey: string, newKey: string) => any;
export type AttributeUpdatedFunc = (product_id: Id, attribute: string, value: string) => any;
export type AttributeDeletedFunc = (product_id: Id, attribute: string) => any;
export type PriceUpdatedFunc = (product_id: Id, price: number) => any;
export type DimensionUpdatedFunc = (product_id: Id, dimension: dimensions_t) => any;
export type ProductEditorItemProps = {
product: Product,
onProductUpdated?: ProductUpdatedFunc,
onProductAdded?: ProductAddedFunc,
onProductDeleted?: ProductDeletedFunc,
onAttributeAdded?: AttributeAddedFunc,
onAttributeKeyChanged?: AttributeKeyUpdatedFunc,
onAttributeUpdated?: AttributeUpdatedFunc,
onAttributeDeleted?: AttributeDeletedFunc,
onPriceUpdated?: PriceUpdatedFunc,
onDimensionsUpdated?: DimensionUpdatedFunc,
}
export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted }: ProductEditorItemProps) => {
export const ProductEditorItem = (props: ProductEditorItemProps) => {
const [showAttributes, setShowAttributes] = useState(false);
const product = props.product;
function onAttributeChanged(product_id: string, key: string, newValue: string) {
product.attributes[key] = newValue;
onProductUpdated && onProductUpdated(product_id, product);
function onAttributeChanged(key: string, newValue: string) {
props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue);
}
function onAttributeDelete(product_id: string, key: string) {
product.removeAttribute(key);
onProductDeleted && onProductDeleted(product_id);
function onAttributeKeyChanged(oldKey: string, newKey: string) {
props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey);
}
function onAttributeDelete(key: string) {
props.onAttributeDeleted && props.onAttributeDeleted(product.id, key);
}
function onPricePerUnitChange(pricePerUnit: string) {
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit));
}
function onUnitsChanged(newUnits: Length) {
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...(product.dimensions as dimensions_t),
u: newUnits,
})
}
function onChangeLength(len: string) {
const l = parseFloat(len) || parseInt(len);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...(product.dimensions as dimensions_t),
l,
})
}
function onChangeWidth(width: string) {
const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...(product.dimensions as dimensions_t),
...(w ? {w} : {}),
})
}
function onDeleteProduct() {
props.onProductDeleted && props.onProductDeleted(product.id);
}
const length = new String(product.dimensions.l || product.dimensions.l || "0") as string;
const width = new String(product.dimensions.w || "") as string;
const dimension = product.dimensions.u || product.dimensions.u || "foot";
return (
<View>
<View style={styles.productListHeader}>
<TouchableHighlight
onPress={() => setShowAttributes(!showAttributes)}
aria-label="Product Item"
style={styles.productItemName}
>
<Text style={styles.product}>{product.attributes.name || `Product ${product.id}`} </Text>
<Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text>
</TouchableHighlight>
<TouchableHighlight
onPress={() => onDeleteProduct()}
aria-label="delete product"
style={styles.deleteProductHighlight}
>
<Ionicons
style={styles.deleteProductButton}
name="trash-outline"
/>
</TouchableHighlight>
</View>
{showAttributes &&
(
<View style={styles.detailsWrapper}>
<View style={styles.priceSpecWrapper}>
<Text style={styles.priceLabel}>$</Text>
<TextInput inputMode="decimal"
defaultValue={new String(product.pricePerUnit) as string}
aria-label="price per unit"
onChangeText={onPricePerUnitChange}
style={styles.priceInput}
/>
<Text style={styles.per}>per</Text>
<Dropdown
data={[
{label: "feet", value: "ft"},
{label: "inches", value: "in"},
]}
style={styles.unitsSelect}
mode="modal"
labelField="label"
valueField="value"
value={product.dimensions.u || "ft"}
onChange={(item) => onUnitsChanged(item.value as Length)}
/>
<TextInput
inputMode="decimal"
defaultValue={length}
onChangeText={onChangeLength}
style={styles.lengthInput}
aria-label="length"
/>
<Text style={{flex: 1,}}>x</Text>
<TextInput
inputMode="decimal"
defaultValue={width}
onChangeText={onChangeWidth}
style={styles.widthInput}
aria-label="width"
/>
</View>
<Button title="+ Add Attribute" onPress={() => props.onAttributeAdded && props.onAttributeAdded(product.id)} />
<FlatList
data={product.attributesAsList}
style={styles.productAttributesList}
data={Object.entries(product.attributes)}
renderItem={({ item }) => (
<ProductAttributeEditor
product={product}
attributeKey={item.key || "some key"}
attributeValue={item.value}
onChange={onAttributeChanged}
attributeKey={item[0] || "some key"}
attributeValue={item[1]}
onChangeAttributeKey={onAttributeKeyChanged}
onChangeAttribute={onAttributeChanged}
onDelete={onAttributeDelete}
/>
)}
keyExtractor={(item) => `${product.id}-${item.key}`}
keyExtractor={(item, i) => `${product.id}-${i}`}
/>
</View>
)
}
</View>
@ -58,5 +164,68 @@ export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted
}
const styles = StyleSheet.create({
product: {},
deleteProductHighlight: {
padding: 5,
borderWidth: 1,
},
deleteProductButton: {
fontSize: 20,
},
detailsWrapper: {
},
priceSpecWrapper: {
flexDirection: "row",
},
priceLabel: {
},
priceInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
per: {
padding: 5,
},
unitsLabel: {
},
unitsSelect: {
flex: 1,
padding: 5,
},
lengthInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
widthInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
productListHeader: {
flexDirection: "row",
padding: 5,
},
productNameText: {
paddingLeft: 10,
paddingRight: 10,
},
productItemName: {
flex: 1,
backgroundColor: "lightgrey",
padding: 4,
margin: 4,
},
productAttributesList: {
margin: 10,
padding: 10,
borderWidth: 1,
borderStyle: "solid",
borderColor: "black",
},
})

View File

@ -0,0 +1,46 @@
import { FlatList, ScrollView, StyleSheet, Text, TouchableHighlight } from "react-native";
import { ProductTile } from "./ProductTile";
import { Id, Product } from "@/lib/product";
import { Key, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { selectProducts } from "@/features/product/productSlice";
import { useAppSelector } from "@/app/store";
export type ProductSelectionProps = {
onProductSelected?: (product: Product) => any;
}
export default function ProductList({ onProductSelected }: ProductSelectionProps) {
const [activeProduct, setActiveProduct] = useState(null as null | Product);
const products = useAppSelector(selectProducts).filter(p => (!!p.dimensions));
function doOnProductSelected(product: Product) {
setActiveProduct(product);
onProductSelected && onProductSelected(product);
}
return (
<ScrollView scrollToOverflowEnabled={true}>
{products.map(product => {
return (
<ProductTile
product={product}
onProductSelected={doOnProductSelected}
isActive={activeProduct === product}
key={product.id}
/>
);
})}
</ScrollView>
)
}
const styles = StyleSheet.create({
productSelectorFlatList: {
padding: 10,
margin: 10,
},
})

View File

@ -1,44 +1,55 @@
import { Product } from "@/lib/product"
import { ImageBackground, StyleProp, StyleSheet, Text, ViewStyle } from "react-native";
import { ImageBackground, StyleProp, StyleSheet, Text, TouchableHighlight, View, ViewStyle } from "react-native";
import { AnimatedStyle } from "react-native-reanimated";
import { View } from "react-native-reanimated/lib/typescript/Animated";
export type OnProductSelectedFunc = (product : Product) => any;
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
type StyleSpec = {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
export type ProductTileProps = {
product: (Product),
onProductSelected?: OnProductSelectedFunc,
style?: {
tile?: MyStyle,
image?: MyStyle,
}
isActive: boolean,
}
const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, style} : ProductTileProps) {
const src = product.attributes.image || FALLBACK_IMAGE;
export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
const k = isActive ? "active" : "default";
return (
<View style={style?.tile}>
<ImageBackground
src={src}
resizeMode="cover"
style={styles.image}
>
<Text style={styles.text}>{product.attributes.name || `Product ${product.id}`}</Text>
<Text style={styles.text}>{ product.pricePerUnit.toString() } / {product.measure.value} {product.measure.unit.symbol} </Text>
</ImageBackground>
</View>
<TouchableHighlight
style={styles[k].highlight}
onPress={() => onProductSelected && onProductSelected(product)}>
<Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text>
</TouchableHighlight>
);
}
const styles = StyleSheet.create({
image: {
const styles = {
active: StyleSheet.create({
highlight: {
padding: 10,
margin: 2,
color: "lightblue",
},
text: {
}
}),
default: StyleSheet.create({
highlight: {
padding: 10,
margin: 2,
backgroundColor: "lightgrey",
},
})
text: {
}
}),
}

View File

@ -0,0 +1,74 @@
import { Length } from "convert";
import { useState } from "react";
import { Button, StyleSheet, Text, TouchableHighlight, View } from "react-native";
export type UnitChooserProps = {
choices: Length[],
onChoicePressed: (l: Length) => any,
activeColor?: string,
defaultColor?: string,
defaultValue? : Length,
}
export default function UnitChooser({ choices, onChoicePressed, activeColor, defaultColor, defaultValue }: UnitChooserProps) {
const [value, setValue] = useState(defaultValue || choices[0] as Length);
activeColor = activeColor || "lightblue";
defaultColor = defaultColor || "lightgrey";
function doChoiceClicked(choice: Length) {
setValue(choice);
onChoicePressed(choice);
}
return (
<View style={styles.unitChooser}>
{choices.map((ci) => {
return (
<TouchableHighlight
onPress={() => doChoiceClicked(ci)}
style={value === ci ? styles.active : styles.default }
key={ci}
>
<Text style={value === ci ? styles.textActive : styles.textDefault}>{ci}</Text>
</TouchableHighlight>
)
})
}
</View>
)
}
const styles = StyleSheet.create({
unitChooser: {
flexDirection: "row",
verticalAlign: "middle",
},
active: {
backgroundColor: "skyblue",
padding: 5,
borderRadius: 5,
},
default: {
backgroundColor: "lightgray",
padding: 5,
borderRadius: 5,
verticalAlign: "middle",
},
textActive: {
marginTop: 2,
marginBottom: 2,
marginLeft: 10,
marginRight: 10,
fontSize: 25,
},
textDefault: {
marginTop: 2,
marginBottom: 2,
marginLeft: 10,
marginRight: 10,
fontSize: 25,
},
unitButton: {
},
})

View File

@ -0,0 +1,31 @@
import { render, fireEvent, screen } from '@testing-library/react-native';
import { AreaInput } from '../AreaInput';
describe('AreaInput', () => {
it('renders correctly', () => {
render(<AreaInput lengthLabel='length' widthLabel='width' />);
const lengthInput = screen.getByLabelText('length');
const widthInput = screen.getByLabelText('width');
expect(lengthInput).toBeTruthy();
expect(widthInput).toBeTruthy();
});
it('calls onValueSet when a value is entered', () => {
const onMeasurementSetMock = jest.fn();
render(<AreaInput onMeasurementSet={onMeasurementSetMock} lengthLabel='length' widthLabel='width' defaultValue={{l: 4, w:4, u: "inch"}}/>);
const lengthInput = screen.getByLabelText('length');
const widthInput = screen.getByLabelText('width');
fireEvent.changeText(lengthInput, '10');
expect(onMeasurementSetMock).toHaveBeenCalledWith({
l: 10,
w: 4,
u: "inch"
});
fireEvent.changeText(widthInput, '10');
expect(onMeasurementSetMock).toHaveBeenCalledWith({
l: 10,
w: 10,
u: "inch"
});
});
});

View File

@ -0,0 +1,18 @@
import { render, fireEvent, screen } from '@testing-library/react-native';
import { MeasurementInput } from '../MeasurementInput';
describe('MeasurementInput', () => {
it('renders correctly', () => {
render(<MeasurementInput units="foot" defaultValue={10} />);
const input = screen.getByLabelText('Enter measurement');
expect(input).toBeTruthy();
});
it('calls onValueSet when value is changed', () => {
const mockOnValueSet = jest.fn();
render(<MeasurementInput units="foot" defaultValue={10} onValueSet={mockOnValueSet} />);
const input = screen.getByLabelText('Enter measurement');
fireEvent.changeText(input, '20');
expect(mockOnValueSet).toHaveBeenCalledWith({ l: 20, u: 'foot' });
});
});

View File

@ -6,10 +6,12 @@ import React from "react";
import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
describe("Product editor tests", () => {
const productName = "Fun Product";
it("Product attributes can be deleted", async () => {
const product = new Product(
100,
area("squareFoot", 4 * 7)
{l: 100, u: "foot"},
{"name" : productName}
);
const onChange = jest.fn();
const onDelete = jest.fn();
@ -18,7 +20,7 @@ describe("Product editor tests", () => {
attributeKey="name"
attributeValue="product"
product={product}
onChange={onChange}
onChangeAttribute={onChange}
onDelete={onDelete}
/>);
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
@ -26,25 +28,28 @@ describe("Product editor tests", () => {
expect(onDelete).toHaveBeenCalled();
});
it("Product attributes can be modified", async () => {
const productName = "Fun Product";
const product = new Product(
100,
area("squareFoot", 4 * 7),
{ name: productName },
{l: 100, u: "foot"},
{"name" : productName}
);
const onChange = jest.fn();
const onDelete = jest.fn();
const onKeyChange = jest.fn();
render(
<ProductAttributeEditor
attributeKey="Name"
attributeValue="product"
product={product}
onChange={onChange}
attributeKey="old test key"
attributeValue="old test value"
onChangeAttribute={onChange}
onDelete={onDelete}
onChangeAttributeKey={onKeyChange}
/>);
fireEvent.press(screen.getByText("product")); // Use getByText instead of findByText
fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
expect(onKeyChange).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
expect(onChange).toHaveBeenCalled();
fireEvent.press(screen.getByLabelText("Delete Attribute"));
expect(onDelete).toHaveBeenCalled();
})
})

View File

@ -0,0 +1,72 @@
import { render, fireEvent, screen, act, within } from '@testing-library/react-native';
import { Provider } from 'react-redux';
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { renderWithProviders } from '@/lib/rendering';
import { Product } from '@/lib/product';
describe('ProductCalculatorSelector', () => {
const mockAreaProduct = new Product(
100,
{ l: 4, w: 8, u: "ft" },
{"name": "area product"},
);
const mockLengthProduct = new Product(
100,
{ l: 4, u: "ft" },
{"name": "length product"},
);
it('renders correctly', () => {
renderWithProviders(
(<ProductCalculatorSelector />),
{
products: [
mockAreaProduct.asObject,
mockLengthProduct.asObject,
],
}
)
expect(screen.getByText('Please select a product')).toBeTruthy();
const label = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
expect(screen.getByText(label)).toBeTruthy();
});
it('a product can be selected', () => {
renderWithProviders(
(<ProductCalculatorSelector />),
{
products: [
mockLengthProduct.asObject,
mockAreaProduct.asObject,
]
}
);
expect(screen.getByText('Please select a product')).toBeTruthy();
const areaLabel = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
const lengthLabel = `${mockLengthProduct.attributes.name} (${mockLengthProduct.pricePerUnitDisplay})`;
fireEvent.press(screen.getByText(areaLabel));
const lengthInput = screen.getByLabelText("enter length");
const widthInput = screen.getByLabelText("enter length");
expect(lengthInput).toBeTruthy();
expect(widthInput).toBeTruthy();
fireEvent.press(screen.getByText("in"));
act(() => {
fireEvent.changeText(lengthInput, "2");
fireEvent.changeText(widthInput, "4");
});
jest.advanceTimersByTime(3000);
const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"});
const sPrice = price.toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2,});
const element = screen.getByLabelText("calculated price");
const {getByText} = within(element);
expect(getByText(sPrice)).toBeTruthy();
});
});

View File

@ -1,25 +1,51 @@
import { renderWithProviders } from "@/lib/rendering";
import { ProductEditor } from "@/components/ProductEditor";
import {products as fixtures} from "@/__fixtures__/initialProducts";
import { screen } from "@testing-library/react-native";
import { act, fireEvent, screen } from "@testing-library/react-native";
import { selectProducts } from "@/features/product/productSlice";
import { Product } from "@/lib/product";
describe("ProductEditor", () => {
const productName = "Flooring"
const mockProduct = new Product(
25,
{ l: 4, w: 8, u: "foot" },
{ name: productName },
)
it("renders correctly", async () => {
const { store } = renderWithProviders(<ProductEditor />, {
products: fixtures,
products: [
mockProduct.asObject,
],
});
const state1 = store.getState();
const products = selectProducts(state1);
let products = selectProducts(state1);
expect(products).toHaveLength(6);
expect(products).toHaveLength(1);
// Check if the product names are rendered
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[1].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[2].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[3].attributes.name as string)).toBeTruthy();
// Start to edit a product
fireEvent.press(screen.getByText(productName));
// Change properties of the product to make sure it's updated in the store
act(() => {
fireEvent.changeText(screen.getByLabelText("length"), "16");
})
products = selectProducts(store.getState());
expect(products[0].dimensions.l).toBe(16);
act(() => {
fireEvent.changeText(screen.getByLabelText("width"), "32");
})
products = selectProducts(store.getState());
expect(products[0].dimensions.w).toBe(32);
fireEvent.press(screen.getByLabelText("delete product"));
products = selectProducts(store.getState());
expect(products.length).toBe(0);
});
});

View File

@ -3,22 +3,36 @@ import { render, fireEvent, screen } from '@testing-library/react-native';
import { ProductEditorItem } from '../ProductEditorItem';
import { Product } from '@/lib/product';
import { area } from 'enheter';
import { renderWithProviders } from '@/lib/rendering';
describe('ProductEditorItem', () => {
const productName = "Product 1";
const mockProduct = new Product(
25,
area("squareFoot", 4 * 8),
{"name": "Product 1"},
{l: 4, u: 'feet'},
{"name": productName},
)
const mockOnProductUpdated = jest.fn();
const onAttributeAdded = jest.fn();
const mockOnProductDeleted = jest.fn();
const onAttributeDeleted = jest.fn();
const onAttributeKeyChanged = jest.fn();
const onAttributeUpdated = jest.fn();
const onProductAdded = jest.fn();
const onPriceUpdated = jest.fn();
const onDimensionUpdated = jest.fn();
it('renders correctly', () => {
render(
<ProductEditorItem
product={mockProduct}
onProductUpdated={mockOnProductUpdated}
onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted}
/>
);
@ -26,15 +40,37 @@ describe('ProductEditorItem', () => {
});
it('calls onProductUpdated when TouchableHighlight is pressed', () => {
render(
const {store} = renderWithProviders(
<ProductEditorItem
product={mockProduct}
onProductUpdated={mockOnProductUpdated}
onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted}
/>
/>, {
products: [mockProduct],
}
);
fireEvent.press(screen.getByText("Product 1"));
expect(screen.getByText('name')).toBeTruthy();
expect(screen.getAllByText('Product 1').length).toEqual(2);
expect(screen.getByLabelText("units")).toBeTruthy();
expect(screen.getByLabelText("Edit Key")).toBeTruthy();
expect(screen.getAllByLabelText("Edit Value").length).toEqual(1);
// Now start modifying the properties.
fireEvent.changeText(screen.getByLabelText("price per unit"), "40.00");
expect(onPriceUpdated).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("length"), "12");
expect(onDimensionUpdated).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("width"), "12");
expect(onDimensionUpdated).toHaveBeenCalled();
fireEvent.press(screen.getByLabelText("delete product"));
expect(mockOnProductDeleted).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import UnitChooser from "../UnitChooser";
import { Length } from 'safe-units';
describe('UnitChooser', () => {
const mockOnChoicePressed = jest.fn();
const choices = ['foot', 'inch'] as Length [];
it('renders correctly', () => {
const { getByText } = render(
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
);
choices.forEach(choice => {
expect(getByText(choice)).toBeTruthy();
});
});
it('calls onChoicePressed when a button is pressed', () => {
const { getByText } = render(
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
);
fireEvent.press(getByText(choices[0]));
expect(mockOnChoicePressed).toHaveBeenCalledWith(choices[0]);
});
});

BIN
doc/images/icons/list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

BIN
doc/images/icons/scale.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

25
eas.json Normal file
View File

@ -0,0 +1,25 @@
{
"cli": {
"version": ">= 10.1.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"android": {
"buildType": "apk"
},
"distribution": "internal"
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}

View File

@ -1,17 +1,41 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Id, Product } from '@/lib/product';
import { dimensions_t, ProductData } from "@/lib/dimensions_t";
import uuid from "react-native-uuid";
import { RootState } from '@/app/store';
import { Length } from 'convert';
const initialState = {
products: [] as Product [],
products: [] as ProductData[],
units: "ft",
};
export type UpdateAttribute = {
product_id: Id,
attributeKey: string,
attributeValue: any,
}
export type UpdateAttributeKey = {
product_id: Id,
oldKey: string,
newKey: string,
}
export type AddAttribute = {
product_id: Id,
}
const cp = (obj: any) => JSON.parse(JSON.stringify(obj));
const productsState = createSlice({
name: 'products-slice',
initialState,
reducers: {
createProduct(state, action: PayloadAction<Product>) {
setUnits(state, action : PayloadAction<Length>) {
state.units = action.payload;
},
createProduct(state, action: PayloadAction<ProductData>) {
if (!state) {
return initialState
}
@ -20,40 +44,152 @@ const productsState = createSlice({
state.products = [...state.products, action.payload];
return state;
},
updateProduct(state, action: PayloadAction<Product>) {
if (!state) return initialState;
const product = action.payload;
if (!product.id) {
throw new Error("Product has no ID");
}
state.products = state.products.map((prod) => {
return prod.id === product.id ? product : prod;
})
return state;
},
deleteProduct(state, action: PayloadAction<Id>) {
if (!state) return initialState;
state.products = state.products.filter((prod) => {
prod.id !== action.payload;
return {
...state,
products: [...state.products.filter((prod) => {
return prod.id?.valueOf() !== action.payload.valueOf();
})],
}
},
updateAttribute(state, action: PayloadAction<UpdateAttribute>) {
const { product_id, attributeKey, attributeValue } = action.payload
if (!state) return initialState;
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[attributeKey] = attributeValue;
return {
...prod,
attributes,
}
})
return state;
};
},
changeKey(state, action: PayloadAction<UpdateAttributeKey>) {
if (!state) return initialState;
const { product_id, oldKey, newKey } = action.payload
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[newKey] = attributes[oldKey];
delete attributes[oldKey];
attributes.id = prod.id;
return {
...prod,
attributes,
}
})
};
},
addAttribute(state, action: PayloadAction<Id>) {
if (!state) return initialState;
const product_id = action.payload;
state.products = state.products.map(prod => {
if (prod.id !== product_id) return prod;
const i = (Object.keys(prod.attributes || {}).filter(k => k.match(/attribute [\d]+/)) || []).length;
const newAttribute = `attribute ${i + 1}`;
return {
...prod,
attributes: {
...prod.attributes,
[newAttribute]: `value`,
}
}
});
return state;
},
export const selectProducts = (state : RootState) => {
deleteAttribute(state, action: PayloadAction<{ product_id: Id, attribute: string }>) {
if (!state) return initialState;
const { product_id, attribute } = action.payload;
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = Object.fromEntries(Object.entries(prod).filter(([k, v]) => (k !== attribute)));
return {
...prod,
attributes,
}
}),
};
},
updatePrice(state, action: PayloadAction<{ product_id: Id, pricePerUnit: number }>) {
if (!state) return initialState;
const { product_id, pricePerUnit } = action.payload;
state.products = state.products.map(prod => {
if (prod.id !== product_id) return prod;
prod.pricePerUnit = pricePerUnit;
return prod;
});
return state;
},
updateDimensions(state, action: PayloadAction<{ product_id: Id, dimensions: dimensions_t }>) {
if (!state) return initialState;
const { product_id, dimensions } = action.payload;
console.log("Changing dimensions: %o", action.payload);
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
return {
...prod,
dimensions,
}
}),
};
},
}
});
export const selectProductsDatas = (state: RootState) => {
return state.products;
}
export const selectUnits = (state : RootState) => {
return (state.units || "ft") as Length;
}
export const selectProducts = createSelector([selectProductsDatas], productsData => {
return productsData.map(d => Product.fromObject(d));
})
export const selectProductIds = createSelector([selectProducts], products => {
return products.map(p => p.id);
})
export const selectProductAttributes = createSelector([selectProducts], products => {
return Object.fromEntries(products.map(p => {
return [
p.id,
p.attributesAsList,
]
}))
})
export const actions = {
...productsState.actions
};
export const {
setUnits,
createProduct,
updateProduct,
deleteProduct,
changeKey,
updateAttribute,
addAttribute,
deleteAttribute,
updatePrice,
updateDimensions,
} = productsState.actions;
export default productsState.reducer;

View File

@ -0,0 +1,14 @@
import { diameterToLength, length_t } from '../dimensions';
describe('diameterToLength', () => {
it('should throw an error if the units of the outer and inner diameters do not match', () => {
expect(() => diameterToLength({ l: 10, u: 'inch' }, { l: 8, u: 'foot' }, 2)).toThrow('diameter units must match!');
});
it('should return the correct length for multiple rings with different units', () => {
const outer : length_t = {l: 25, u: "in"};
const inner: length_t = {l : 1, u: "in"};
const l = diameterToLength(outer, inner, 12);
expect(l.l).toBeCloseTo(490, -1.0);
});
});

View File

@ -20,4 +20,11 @@ describe("Product tests", () => {
const comparison = standard.priceFor({l : 24, u: "inch"});
expect(comparison).toBeCloseTo(20, 4);
});
it("Can convert to/from object", () => {
const standard = new Product(10, {l: 1, u : "feet"});
const obj = standard.asObject;
const back = Product.fromObject(obj);
expect(back).toEqual(standard);
})
});

52
lib/dimensions.ts Normal file
View File

@ -0,0 +1,52 @@
import convert from "convert";
import { Length } from "convert";
export type length_t = {
l: number; u: Length;
};
export type area_t = length_t & {
w: number;
};
export type dimensions_t = area_t | length_t;
export type product_type_t = "area" | "length";
export const isArea = (d: dimensions_t) => ("width" in d);
export const isLength = (d: dimensions_t) => (!("width" in d));
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"; export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
if (!(
(isArea(d1) && isArea(d2)) ||
(isLength(d1) && isLength(d2))
)) {
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
}
return {
l: convert(d1.l, d1.u).to(d2.u),
u: d2.u,
...(
"w" in d1 ?
{ w: convert(d1.w, d1.u).to(d2.u), }
: {}
)
};
}
/**
* Gets the total length of a carpet roll based on a diameter
* @param outerDiameter Outer diameter of the carpet roll
* @param innerDiameter Inner diameter of the carpet roll (the "hole")
* @param numRings Number of "rings" or "layers,"" just like a tree 🙂 🌲
*/
export function diameterToLength(outerDiameter: length_t, innerDiameter: length_t, numRings: number) {
if (outerDiameter.u !== innerDiameter.u) {
throw new Error("diameter units must match!")
}
const thickness = (((outerDiameter.l - innerDiameter.l) / 2) / numRings);
return {
l: Math.PI * (Math.pow(outerDiameter.l, 2) / 4) - (Math.pow(innerDiameter.l, 2) / 4) / thickness,
u: innerDiameter.u,
};
}

View File

@ -1,5 +1,8 @@
import uuid from "react-native-uuid";
import convert, { Area, Length } from "convert";
import { Area } from "convert";
import { Transform } from "class-transformer";
import { dimensions_t, area_t } from "./dimensions";
import { matchDimensions } from "./dimensions";
export type Id = string;
@ -14,77 +17,50 @@ export type ProductAttributes = {
currency?: Currency,
// [index:string]: any,
}
export function dimensionArea(d: dimensions_t) {
return "w" in d ? d.w * d.l : 0;
}
export type ProductData = {
id?: Id,
pricePerUnit: number,
measurement: {
unit: string,
value: number,
dimension: number,
},
attributes?: ProductAttributes,
id?: Id;
pricePerUnit: number;
dimensions: dimensions_t;
attributes?: ProductAttributes;
};
export type length_t = {
l: number, u: Length
}
export type area_t = length_t & {
w: number,
}
export type dimensions_t = area_t | length_t;
export type product_type_t = "area" | "length";
export const isArea = (d: dimensions_t) => ("width" in d);
export const isLength = (d: dimensions_t) => (!("width" in d));
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"
export class Product {
public id: string;
public area?: area_t;
public length?: length_t;
public presentUnits: Length;
constructor(public pricePerUnit: number, dimensions: dimensions_t, public attributes: ProductAttributes = {},) {
this.id = attributes.id || uuid.v4().toString();
this.presentUnits = dimensions.u;
if ("w" in dimensions) {
this.area = {
l: convert(dimensions.l, dimensions.u).to("meter"),
w: convert(dimensions.w, dimensions.u).to("meter"),
u: "meter"
}
} else {
this.length = {
l: convert(dimensions.l, dimensions.u).to("meter"),
u: "meter"
};
}
public id?: Id;
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
id?: Id,
) {
this.id = id || uuid.v4().toString();
}
public priceFor(dimensions: dimensions_t): number {
if (this.area && "w" in dimensions) {
const thisA = this.area.l * this.area.w;
const otherA = convert(
dimensions.w,
dimensions.u
).to("meter") * convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherA / thisA) * this.pricePerUnit;
} if (this.length) {
const thisL = this.length.l;
const otherL = convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherL / thisL) * this.pricePerUnit;
public priceFor(dimensions: dimensions_t, damage : number): number {
if (Number.isNaN(damage)) damage = 0;
const dim = matchDimensions(dimensions, this.dimensions);
return (
dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
: (dim.l / this.dimensions.l) * this.pricePerUnit
) * (1.0 - damage);
}
throw new Error(`Invalid dimensions: ${dimensions}`);
get priceDisplay() {
return this.pricePerUnit.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
get pricePerUnitDisplay() {
const p = this.priceDisplay;
const { l, u } = this.dimensions;
const w = (this.dimensions as area_t).w || null;
const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
return `$${p} per ${d}`
}
get attributesAsList() {
@ -94,6 +70,30 @@ export class Product {
}
public removeAttribute(key: string) {
delete this.attributes[key];
this.attributes = Object.fromEntries(
Object.entries(this.attributes).filter(
([k, v]) => {
k == key;
}
)
);
}
get asObject(): ProductData {
return {
id: this.id,
pricePerUnit: this.pricePerUnit,
dimensions: this.dimensions,
attributes: this.attributes,
}
}
static fromObject({ id, pricePerUnit, dimensions, attributes }: ProductData) {
return new Product(
pricePerUnit,
dimensions,
attributes,
id,
)
}
}

View File

@ -3,6 +3,7 @@ import { PropsWithChildren, ReactElement } from "react";
import { Provider } from "react-redux";
import { setupStore, RootState } from "@/app/store";
import { Product } from "@/lib/product";
import { ProductData } from "./product";
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>;
@ -12,7 +13,7 @@ export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
export function renderWithProviders(
ui: ReactElement,
preloadedState = {
products: [] as Product []
products: [] as ProductData []
},
extendedRenderOptions: ExtendedRenderOptions = {},
) {

0
lib/util.ts Normal file
View File

18022
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,64 +5,62 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web --offline",
"test": "jest --watchAll",
"lint": "expo lint"
"lint": "expo lint",
"apk:android": "eas build --platform android --local"
},
"dependencies": {
"@babel/runtime": "^7.24.7",
"@expo/config-plugins": "~8.0.0",
"@expo/prebuild-config": "~7.0.0",
"@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native/assets-registry": "^0.74.84",
"@react-navigation/native": "^6.1.17",
"@reduxjs/toolkit": "^2.2.5",
"@testing-library/react-native": "^12.5.1",
"@types/js-quantities": "^1.6.6",
"@react-native-community/slider": "^4.5.2",
"@react-native/assets-registry": "^0.74.85",
"@react-navigation/native": "^6.0.2",
"@reduxjs/toolkit": "^2.2.6",
"class-transformer": "^0.5.1",
"convert": "^5.3.0",
"enheter": "^1.0.27",
"esm": "link:@types/js-quantities/esm",
"expo": "~51.0.16",
"dayjs": "^1.11.11",
"expo": "~51.0.18",
"expo-asset": "^10.0.10",
"expo-constants": "~16.0.2",
"expo-doctor": "^1.6.1",
"expo-font": "~12.0.7",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.17",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.6",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.3",
"interopRequireDefault": "link:@babel/runtime/helpers/interopRequireDefault",
"js-quantities": "^1.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.2",
"react-native-gesture-handler": "~2.16.2",
"react-native": "0.74.3",
"react-native-element-dropdown": "^2.12.1",
"react-native-gesture-handler": "~2.16.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"react-native-select-dropdown": "^4.0.1",
"react-native-uuid": "^2.0.2",
"react-native-web": "~0.19.12",
"react-native-web": "~0.19.10",
"react-redux": "^9.1.2",
"redux-remember": "^5.1.0",
"safe-units": "^2.0.1",
"uuid": "^10.0.0"
"redux-remember": "^5.1.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/core": "^7.20.0",
"@babel/preset-typescript": "^7.24.7",
"@jest/globals": "^29.7.0",
"@testing-library/react-native": "^12.5.1",
"@types/jest": "^29.5.12",
"@types/react": "~18.2.79",
"@types/react-test-renderer": "^18.3.0",
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"enzyme-adapter-react-15": "^1.4.4",
"eslint-config-airbnb": "^19.0.4",
"jest": "^29.7.0",
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"babel-preset-expo": "^11.0.12",
"jest": "^29.2.1",
"jest-expo": "~51.0.3",
"react-test-renderer": "18.2.0",
"ts-jest": "^29.1.5",
"typescript": "~5.3.3"
},
"private": true

4785
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff