Compare commits

...

10 Commits

Author SHA1 Message Date
Jordan
466e005e4e pushing to remote repo. 2024-07-02 07:00:27 -07:00
Jordan
7076d33287 successful aes build. 2024-07-02 06:59:51 -07:00
Jordan
012fd77a10 start eas build. 2024-07-01 13:01:25 -07:00
Jordan
ecdc9db085 good enough for government (or habitat) work 2024-07-01 12:23:45 -07:00
Jordan Hewitt
379f43dcd9 complete more of the unit tests. 2024-07-01 08:05:24 -07:00
Jordan
76fe4eb34a Generated component unittests. 2024-07-01 06:15:43 -07:00
Jordan
fb68beb1b3 start to refactor components. 2024-06-30 19:49:41 -07:00
Jordan
408a996fe7 change product to dump to object before storing in redix. TODO: solve dimensions issue. 2024-06-30 09:37:27 -07:00
Jordan
de0167e9e5 made it look prettier. working on fixing product editor. 2024-06-29 06:09:22 -07:00
Jordan Hewitt
7c2289098e working on getting input to respond correctly. 2024-06-28 17:04:30 -07:00
38 changed files with 4004 additions and 19924 deletions

5
.gitignore vendored
View File

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

View File

@ -1,4 +1,23 @@
# Welcome to your Expo app 👋 # PliWould - Measure And Price Sheet Good Merchandise
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
[[ TODO ]]
# Development Docs
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).

View File

@ -2,11 +2,20 @@ import { Product } from "@/lib/product";
export const products = [ export const products = [
// Sheet goods // Sheet goods
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }), new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }), new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }), new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }), new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
// Beams and trim new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }), new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }), 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

@ -19,7 +19,8 @@
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} },
"package": "tech.damngood.PliWould"
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@ -31,6 +32,14 @@
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "bf9125c3-72d0-42a7-9480-74c4717e7ed3"
}
} }
} }
} }

View File

@ -1,14 +1,19 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { TabBarIcon } from '@/components/navigation/TabBarIcon'; 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() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const store = setupStore({
products: fixtures.map(p => p.asObject)
});
return ( return (
<Provider store={store}>
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
@ -17,9 +22,9 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Conversion', title: 'Home Screen',
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} /> <TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
), ),
}} }}
/> />
@ -28,10 +33,11 @@ export default function TabLayout() {
options={{ options={{
title: 'Products', title: 'Products',
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} /> <TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
), ),
}} }}
/> />
</Tabs> </Tabs>
</Provider>
); );
} }

View File

@ -1,50 +1,10 @@
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native'; import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { SafeAreaView, Text, View } 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) => {
}
export default function Convert () {
return ( return (
<SafeAreaView> <View>
<MeasurementInput onMeasurementSet={calculatePrice} /> <ProductCalculatorSelector />
{products.map((product) => { </View>
<ProductTile product={product} onProductSelected={selectProduct} /> )
})}
</SafeAreaView>
);
} }
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 { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { MeasurementInput } from '@/components/LengthInput'; import { ProductEditor } from '@/components/ProductEditor';
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';
export default function HomeScreen() { export default function HomeScreen() {

View File

@ -3,14 +3,14 @@ import { configureStore } from '@reduxjs/toolkit';
import { rememberReducer, rememberEnhancer } from 'redux-remember'; import { rememberReducer, rememberEnhancer } from 'redux-remember';
import reducers from "@/features/product/productSlice" import reducers from "@/features/product/productSlice"
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, } from "@/lib/product"; import { Product, ProductData, } from "@/lib/product";
const rememberedKeys = ['products']; const rememberedKeys = ['products'];
const rootReducer = reducers; const rootReducer = reducers;
export function setupStore(preloadedState = { export function setupStore(preloadedState = {
products: [] as Product[], products: [] as ProductData[],
}) { }) {
return configureStore({ return configureStore({
reducer: rememberReducer(reducers), reducer: rememberReducer(reducers),

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

61
components/AreaInput.tsx Normal file
View File

@ -0,0 +1,61 @@
import { MeasurementInput } from "./MeasurementInput";
import { area_t, dimensions_t } from "@/lib/product";
import { useState } from "react";
import { StyleSheet, View } from "react-native";
export type AreaInputProps = {
onMeasurementSet?: (area : dimensions_t) => any,
defaultValue?: area_t,
lengthLabel?: string,
widthLabel?: string,
}
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue} : AreaInputProps) {
defaultValue = defaultValue || {l: 0, w: 0, u: "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}
/>
<MeasurementInput
defaultValue={{l: area.w, u: area.u}}
onValueSet={doOnWidthSet}
label={widthLabel}
/>
</View>
)
}
const styles = StyleSheet.create({
areaInputWrapper: {
flexDirection: "row"
}
})

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,62 @@
import { dimensions_t, length_t } from "@/lib/product";
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,
}
export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementInputProps) {
const [mValue, setMValue] = useState(defaultValue)
const defValue = Number.isNaN(defaultValue.l) ? 0 : defaultValue.l
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}>{mValue.u}</Text>
</View>
)
}
const styles = StyleSheet.create({
inputWrapper: {
alignItems: "flex-start",
flexDirection: "row"
},
unitHints: {
padding: 10,
},
lengthInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 25,
width: 100,
},
})

View File

@ -0,0 +1,44 @@
import { StyleSheet, Text, TextInput, View } from "react-native";
import Slider from '@react-native-community/slider';
import { useState } from "react";
type PercentDamageProps = {
onSetPercentage: (percent: number) => any;
}
export default function PercentDamage ({onSetPercentage} : PercentDamageProps) {
const [damage, setDamage] = useState(0);
function doOnChangeText (val : number) {
setDamage(val);
onSetPercentage(val / 100);
}
return (
<View style={styles.wrapper}>
<Slider
value={damage}
minimumValue={0}
maximumValue={100}
step={5}
onValueChange={doOnChangeText}
/>
<Text style={styles.label}> {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,
}
})

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 { useState } from "react";
import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native"; import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native";
export type ProductAttributeChangeFunc = (product_id: string, key: string, newValue: string) => any; export type ProductAttributeChangeFunc = (key: string, newValue: string) => any;
export type ProductAttributeDeleteFunc = (product_id: string, key: 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) => { export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => {
const [doEdit, setDoEdit] = useState(true);
const [newValue, setNewValue] = useState(value);
const doChange = (e: any) => { const doChangeKey = (e: any) => {
setNewValue(e); onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
onChange && onChange(product.id, key, e); }
const doChangeValue = (e: any) => {
onChangeAttribute && onChangeAttribute(attributeKey, e);
} }
return ( return (
<View> <View>
<Text>{key}</Text> <View style={styles.productAttributeRow}>
<View> <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 <TouchableHighlight
onPress={() => setDoEdit(!doEdit)} onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
aria-label="Property Value" aria-label="Delete Attribute"
> style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}>
{doEdit ? <Ionicons name="trash-bin-outline" size={30} color={"white"} />
(<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" />
</TouchableHighlight> </TouchableHighlight>
</View> </View>
</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,198 @@
import { Product, dimensions_t } from '@/lib/product';
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'
/>
:
<MeasurementInput
defaultValue={activeProduct.dimensions}
onValueSet={onMeasurementSet}
label="enter length"
/>
) : (
<Text>Please select a product</Text>
)
}
{
activeProduct && <UnitChooser choices={["in", "ft"]} onChoicePressed={onUnitChosen} />
}
</View>
</View>
{activeProduct &&
(<View >
<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",
},
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,
},
});

View File

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

View File

@ -1,56 +1,161 @@
import { Product } from "@/lib/product" import { Id, Product, dimensions_t } from "@/lib/product"
import { useState } from "react" 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 { 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 ProductAddedFunc = () => any;
export type ProductDeletedFunc = (product_id: string) => 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 = { export type ProductEditorItemProps = {
product: Product, product: Product,
onProductUpdated?: ProductUpdatedFunc, onProductAdded?: ProductAddedFunc,
onProductDeleted?: ProductDeletedFunc, 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 [showAttributes, setShowAttributes] = useState(false);
const product = props.product;
function onAttributeChanged(product_id: string, key: string, newValue: string) { function onAttributeChanged(key: string, newValue: string) {
product.attributes[key] = newValue; props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue);
onProductUpdated && onProductUpdated(product_id, product);
} }
function onAttributeDelete(product_id: string, key: string) { function onAttributeKeyChanged(oldKey: string, newKey: string) {
product.removeAttribute(key); props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey);
onProductDeleted && onProductDeleted(product_id);
} }
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 ( return (
<View> <View>
<View style={styles.productListHeader}>
<TouchableHighlight <TouchableHighlight
onPress={() => setShowAttributes(!showAttributes)} onPress={() => setShowAttributes(!showAttributes)}
aria-label="Product Item" 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>
<TouchableHighlight
onPress={() => onDeleteProduct()}
aria-label="delete product"
style={styles.deleteProductHighlight}
>
<Ionicons
style={styles.deleteProductButton}
name="trash-outline"
/>
</TouchableHighlight>
</View>
{showAttributes && {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 <FlatList
data={product.attributesAsList} style={styles.productAttributesList}
data={Object.entries(product.attributes)}
renderItem={({ item }) => ( renderItem={({ item }) => (
<ProductAttributeEditor <ProductAttributeEditor
product={product} attributeKey={item[0] || "some key"}
attributeKey={item.key || "some key"} attributeValue={item[1]}
attributeValue={item.value} onChangeAttributeKey={onAttributeKeyChanged}
onChange={onAttributeChanged} onChangeAttribute={onAttributeChanged}
onDelete={onAttributeDelete} onDelete={onAttributeDelete}
/> />
)} )}
keyExtractor={(item) => `${product.id}-${item.key}`} keyExtractor={(item, i) => `${product.id}-${i}`}
/> />
</View>
) )
} }
</View> </View>
@ -58,5 +163,68 @@ export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted
} }
const styles = StyleSheet.create({ 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 { 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 { AnimatedStyle } from "react-native-reanimated";
import { View } from "react-native-reanimated/lib/typescript/Animated";
export type OnProductSelectedFunc = (product : Product) => any; export type OnProductSelectedFunc = (product : Product) => any;
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>; type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
type StyleSpec = {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
export type ProductTileProps = { export type ProductTileProps = {
product: (Product), product: (Product),
onProductSelected?: OnProductSelectedFunc, onProductSelected?: OnProductSelectedFunc,
style?: { isActive: boolean,
tile?: MyStyle,
image?: MyStyle,
}
} }
const FALLBACK_IMAGE = ""; const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, style} : ProductTileProps) { export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
const src = product.attributes.image || FALLBACK_IMAGE; const k = isActive ? "active" : "default";
return ( return (
<View style={style?.tile}>
<ImageBackground <TouchableHighlight
src={src} style={styles[k].highlight}
resizeMode="cover" onPress={() => onProductSelected && onProductSelected(product)}>
style={styles.image} <Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text>
> </TouchableHighlight>
<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>
); );
} }
const styles = StyleSheet.create({ const styles = {
image: { active: StyleSheet.create({
highlight: {
padding: 10,
margin: 2,
color: "lightblue",
}, },
text: { text: {
}
}),
default: StyleSheet.create({
highlight: {
padding: 10,
margin: 2,
backgroundColor: "lightgrey",
}, },
}) text: {
}
}),
}

View File

@ -0,0 +1,49 @@
import { Length } from "convert";
import { useState } from "react";
import { Button, StyleSheet, View } from "react-native";
export type UnitChooserProps = {
choices: Length[],
onChoicePressed: (l: Length) => any,
activeColor?: string,
defaultColor?: string,
}
export default function UnitChooser({ choices, onChoicePressed, activeColor, defaultColor }: UnitChooserProps) {
const [value, setValue] = useState(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 (
<Button
title={ci}
onPress={() => doChoiceClicked(ci)}
color={value === ci ? activeColor : defaultColor}
/>
)
})
}
</View>
)
}
const styles = StyleSheet.create({
unitChooser: {
},
active: {
},
default: {
}
})

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"; import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
describe("Product editor tests", () => { describe("Product editor tests", () => {
const productName = "Fun Product";
it("Product attributes can be deleted", async () => { it("Product attributes can be deleted", async () => {
const product = new Product( const product = new Product(
100, 100,
area("squareFoot", 4 * 7) {l: 100, u: "foot"},
{"name" : productName}
); );
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
@ -18,7 +20,7 @@ describe("Product editor tests", () => {
attributeKey="name" attributeKey="name"
attributeValue="product" attributeValue="product"
product={product} product={product}
onChange={onChange} onChangeAttribute={onChange}
onDelete={onDelete} onDelete={onDelete}
/>); />);
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull(); expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
@ -26,25 +28,28 @@ describe("Product editor tests", () => {
expect(onDelete).toHaveBeenCalled(); expect(onDelete).toHaveBeenCalled();
}); });
it("Product attributes can be modified", async () => { it("Product attributes can be modified", async () => {
const productName = "Fun Product";
const product = new Product( const product = new Product(
100, 100,
area("squareFoot", 4 * 7), {l: 100, u: "foot"},
{ name: productName }, {"name" : productName}
); );
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
const onKeyChange = jest.fn();
render( render(
<ProductAttributeEditor <ProductAttributeEditor
attributeKey="Name" attributeKey="old test key"
attributeValue="product" attributeValue="old test value"
product={product} onChangeAttribute={onChange}
onChange={onChange}
onDelete={onDelete} 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"); fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
expect(onChange).toHaveBeenCalled(); 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 { renderWithProviders } from "@/lib/rendering";
import { ProductEditor } from "@/components/ProductEditor"; import { ProductEditor } from "@/components/ProductEditor";
import {products as fixtures} from "@/__fixtures__/initialProducts"; import { act, fireEvent, screen } from "@testing-library/react-native";
import { screen } from "@testing-library/react-native";
import { selectProducts } from "@/features/product/productSlice"; import { selectProducts } from "@/features/product/productSlice";
import { Product } from "@/lib/product";
describe("ProductEditor", () => { describe("ProductEditor", () => {
const productName = "Flooring"
const mockProduct = new Product(
25,
{ l: 4, w: 8, u: "foot" },
{ name: productName },
)
it("renders correctly", async () => { it("renders correctly", async () => {
const {store} = renderWithProviders(<ProductEditor />, { const { store } = renderWithProviders(<ProductEditor />, {
products: fixtures, products: [
mockProduct.asObject,
],
}); });
const state1 = store.getState(); 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 // Check if the product names are rendered
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy(); 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(); // Start to edit a product
expect(screen.getByText(products[3].attributes.name as string)).toBeTruthy(); 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 { ProductEditorItem } from '../ProductEditorItem';
import { Product } from '@/lib/product'; import { Product } from '@/lib/product';
import { area } from 'enheter'; import { area } from 'enheter';
import { renderWithProviders } from '@/lib/rendering';
describe('ProductEditorItem', () => { describe('ProductEditorItem', () => {
const productName = "Product 1";
const mockProduct = new Product( const mockProduct = new Product(
25, 25,
area("squareFoot", 4 * 8), {l: 4, u: 'feet'},
{"name": "Product 1"}, {"name": productName},
) )
const mockOnProductUpdated = jest.fn(); const onAttributeAdded = jest.fn();
const mockOnProductDeleted = 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', () => { it('renders correctly', () => {
render( render(
<ProductEditorItem <ProductEditorItem
product={mockProduct} product={mockProduct}
onProductUpdated={mockOnProductUpdated} onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted} onProductDeleted={mockOnProductDeleted}
/> />
); );
@ -26,15 +40,37 @@ describe('ProductEditorItem', () => {
}); });
it('calls onProductUpdated when TouchableHighlight is pressed', () => { it('calls onProductUpdated when TouchableHighlight is pressed', () => {
render( const {store} = renderWithProviders(
<ProductEditorItem <ProductEditorItem
product={mockProduct} product={mockProduct}
onProductUpdated={mockOnProductUpdated} onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted} onProductDeleted={mockOnProductDeleted}
/> />, {
products: [mockProduct],
}
); );
fireEvent.press(screen.getByText("Product 1")); fireEvent.press(screen.getByText("Product 1"));
expect(screen.getByText('name')).toBeTruthy(); expect(screen.getByLabelText("units")).toBeTruthy();
expect(screen.getAllByText('Product 1').length).toEqual(2); 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]);
});
});

18
eas.json Normal file
View File

@ -0,0 +1,18 @@
{
"cli": {
"version": ">= 10.0.3"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}

View File

@ -1,17 +1,36 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Id, Product } from '@/lib/product'; import { area_t, dimensions_t, Id, length_t, Product, ProductData } from '@/lib/product';
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
import { classToPlain, plainToClass } from 'class-transformer';
const initialState = { const initialState = {
products: [] as Product [], products: [] as ProductData[],
} }
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({ const productsState = createSlice({
name: 'products-slice', name: 'products-slice',
initialState, initialState,
reducers: { reducers: {
createProduct(state, action: PayloadAction<Product>) { createProduct(state, action: PayloadAction<ProductData>) {
if (!state) { if (!state) {
return initialState return initialState
} }
@ -20,31 +39,133 @@ const productsState = createSlice({
state.products = [...state.products, action.payload]; state.products = [...state.products, action.payload];
return state; 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>) { deleteProduct(state, action: PayloadAction<Id>) {
if (!state) return initialState; if (!state) return initialState;
state.products = state.products.filter((prod) => { return {
prod.id !== action.payload; ...state,
}) products: [...state.products.filter((prod) => {
return state; 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,
}
})
};
},
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;
},
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 selectProducts = (state : RootState) => { export const selectProductsDatas = (state: RootState) => {
return state.products; return state.products;
} }
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 = { export const actions = {
...productsState.actions ...productsState.actions
@ -52,8 +173,13 @@ export const actions = {
export const { export const {
createProduct, createProduct,
updateProduct,
deleteProduct, deleteProduct,
changeKey,
updateAttribute,
addAttribute,
deleteAttribute,
updatePrice,
updateDimensions,
} = productsState.actions; } = productsState.actions;
export default productsState.reducer; export default productsState.reducer;

View File

@ -20,4 +20,11 @@ describe("Product tests", () => {
const comparison = standard.priceFor({l : 24, u: "inch"}); const comparison = standard.priceFor({l : 24, u: "inch"});
expect(comparison).toBeCloseTo(20, 4); 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);
})
}); });

View File

@ -1,5 +1,6 @@
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import convert, { Area, Length } from "convert"; import convert, { Area, Length } from "convert";
import { Transform } from "class-transformer";
export type Id = string; export type Id = string;
@ -14,18 +15,6 @@ export type ProductAttributes = {
currency?: Currency, currency?: Currency,
// [index:string]: any, // [index:string]: any,
} }
export type ProductData = {
id?: Id,
pricePerUnit: number,
measurement: {
unit: string,
value: number,
dimension: number,
},
attributes?: ProductAttributes,
};
export type length_t = { export type length_t = {
l: number, u: Length l: number, u: Length
} }
@ -36,55 +25,77 @@ export type area_t = length_t & {
export type dimensions_t = area_t | length_t; export type dimensions_t = area_t | length_t;
export type ProductData = {
id?: Id,
pricePerUnit: number,
dimensions: dimensions_t,
attributes?: ProductAttributes,
};
export type product_type_t = "area" | "length"; export type product_type_t = "area" | "length";
export const isArea = (d: dimensions_t) => ("width" in d); export const isArea = (d: dimensions_t) => ("width" in d);
export const isLength = (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 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), }
: {}
)
}
}
export function dimensionArea(d: dimensions_t) {
return "w" in d ? d.w * d.l : 0;
}
export class Product { 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 = {},) { public id?: Id;
this.id = attributes.id || uuid.v4().toString();
this.presentUnits = dimensions.u; constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
if ("w" in dimensions) { id?: Id,
this.area = { ) {
l: convert(dimensions.l, dimensions.u).to("meter"), this.id = id || uuid.v4().toString();
w: convert(dimensions.w, dimensions.u).to("meter"),
u: "meter"
}
} else {
this.length = {
l: convert(dimensions.l, dimensions.u).to("meter"),
u: "meter"
};
}
} }
public priceFor(dimensions: dimensions_t): number { public priceFor(dimensions: dimensions_t, damage : number): number {
if (this.area && "w" in dimensions) { if (Number.isNaN(damage)) damage = 0;
const thisA = this.area.l * this.area.w; const dim = matchDimensions(dimensions, this.dimensions);
const otherA = convert( return (
dimensions.w, dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
dimensions.u : (dim.l / this.dimensions.l) * this.pricePerUnit
).to("meter") * convert( ) * (1.0 - damage);
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;
} }
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() { get attributesAsList() {
@ -94,6 +105,30 @@ export class Product {
} }
public removeAttribute(key: string) { 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

@ -2,7 +2,7 @@ import { RenderOptions, render } from "@testing-library/react-native";
import { PropsWithChildren, ReactElement } from "react"; import { PropsWithChildren, ReactElement } from "react";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { setupStore, RootState } from "@/app/store"; import { setupStore, RootState } from "@/app/store";
import { Product } from "@/lib/product"; import { Product, ProductData } from "@/lib/product";
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> { export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>; preloadedState?: Partial<RootState>;
@ -12,7 +12,7 @@ export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
export function renderWithProviders( export function renderWithProviders(
ui: ReactElement, ui: ReactElement,
preloadedState = { preloadedState = {
products: [] as Product [] products: [] as ProductData []
}, },
extendedRenderOptions: ExtendedRenderOptions = {}, 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,9 +5,9 @@
"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 --offline",
"test": "jest --watchAll", "test": "jest --watchAll",
"lint": "expo lint" "lint": "expo lint"
}, },
@ -15,15 +15,16 @@
"@babel/runtime": "^7.24.7", "@babel/runtime": "^7.24.7",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1", "@react-native-async-storage/async-storage": "^1.23.1",
"@react-native/assets-registry": "^0.74.84", "@react-native-community/slider": "^4.5.2",
"@react-native/assets-registry": "^0.74.85",
"@react-navigation/native": "^6.1.17", "@react-navigation/native": "^6.1.17",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.6",
"@testing-library/react-native": "^12.5.1", "@testing-library/react-native": "^12.5.1",
"@types/js-quantities": "^1.6.6", "@types/js-quantities": "^1.6.6",
"class-transformer": "^0.5.1",
"convert": "^5.3.0", "convert": "^5.3.0",
"enheter": "^1.0.27", "enheter": "^1.0.27",
"esm": "link:@types/js-quantities/esm", "expo": "~51.0.17",
"expo": "~51.0.16",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-font": "~12.0.7", "expo-font": "~12.0.7",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
@ -32,19 +33,22 @@
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.6", "expo-system-ui": "~3.0.6",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"interopRequireDefault": "link:@babel/runtime/helpers/interopRequireDefault",
"js-quantities": "^1.8.0", "js-quantities": "^1.8.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.2", "react-native": "0.74.2",
"react-native-element-dropdown": "^2.12.1",
"react-native-flex-grid": "^1.0.4",
"react-native-gesture-handler": "~2.16.2", "react-native-gesture-handler": "~2.16.2",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1", "react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-select-dropdown": "^4.0.1",
"react-native-uuid": "^2.0.2", "react-native-uuid": "^2.0.2",
"react-native-web": "~0.19.12", "react-native-web": "~0.19.12",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"redux-remember": "^5.1.0", "redux-remember": "^5.1.0",
"rfdc": "^1.4.1",
"safe-units": "^2.0.1", "safe-units": "^2.0.1",
"uuid": "^10.0.0" "uuid": "^10.0.0"
}, },
@ -57,6 +61,7 @@
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "^18.3.0",
"babel-plugin-transform-es2015-destructuring": "^6.23.0", "babel-plugin-transform-es2015-destructuring": "^6.23.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"eas-cli": "^10.1.0",
"enzyme-adapter-react-15": "^1.4.4", "enzyme-adapter-react-15": "^1.4.4",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"jest": "^29.7.0", "jest": "^29.7.0",

File diff suppressed because it is too large Load Diff