Compare commits
10 Commits
93c0c25eb5
...
466e005e4e
Author | SHA1 | Date | |
---|---|---|---|
|
466e005e4e | ||
|
7076d33287 | ||
|
012fd77a10 | ||
|
ecdc9db085 | ||
|
379f43dcd9 | ||
|
76fe4eb34a | ||
|
fb68beb1b3 | ||
|
408a996fe7 | ||
|
de0167e9e5 | ||
|
7c2289098e |
5
.gitignore
vendored
5
.gitignore
vendored
@ -17,4 +17,7 @@ web-build/
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
# @end expo-cliandroid
|
||||
android
|
||||
builds
|
||||
.env
|
||||
|
21
README.md
21
README.md
@ -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).
|
||||
|
||||
|
@ -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(25, {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"}),
|
||||
];
|
11
app.json
11
app.json
@ -19,7 +19,8 @@
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"package": "tech.damngood.PliWould"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -31,6 +32,14 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "bf9125c3-72d0-42a7-9480-74c4717e7ed3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,19 @@
|
||||
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)
|
||||
});
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
@ -17,9 +22,9 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Conversion',
|
||||
title: 'Home Screen',
|
||||
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={{
|
||||
title: 'Products',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
||||
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
@ -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() {
|
||||
|
||||
|
@ -3,14 +3,14 @@ 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 { Product, ProductData, } from "@/lib/product";
|
||||
|
||||
const rememberedKeys = ['products'];
|
||||
|
||||
const rootReducer = reducers;
|
||||
|
||||
export function setupStore(preloadedState = {
|
||||
products: [] as Product[],
|
||||
products: [] as ProductData[],
|
||||
}) {
|
||||
return configureStore({
|
||||
reducer: rememberReducer(reducers),
|
||||
|
BIN
assets/images/board-stock-lightened-blurred.png
Normal file
BIN
assets/images/board-stock-lightened-blurred.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 72 KiB |
BIN
assets/images/board-stock.png
Normal file
BIN
assets/images/board-stock.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 292 KiB |
61
components/AreaInput.tsx
Normal file
61
components/AreaInput.tsx
Normal 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"
|
||||
}
|
||||
})
|
@ -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,
|
||||
}
|
||||
})
|
62
components/MeasurementInput.tsx
Normal file
62
components/MeasurementInput.tsx
Normal 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,
|
||||
},
|
||||
})
|
44
components/PercentDamange.tsx
Normal file
44
components/PercentDamange.tsx
Normal 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
38
components/Price.tsx
Normal 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,
|
||||
}
|
||||
});
|
@ -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
|
||||
}
|
||||
});
|
198
components/ProductCalculatorSelector.tsx
Normal file
198
components/ProductCalculatorSelector.tsx
Normal 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,
|
||||
},
|
||||
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
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, dimensions_t } from "@/lib/product";
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
|
||||
import { ProductEditorItem } from "./ProductEditorItem";
|
||||
|
||||
@ -13,23 +13,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 +67,10 @@ export const ProductEditor = ({}) => {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
h1: {
|
||||
textAlign: "center",
|
||||
fontFamily: "sans-serif"
|
||||
},
|
||||
product: {
|
||||
|
||||
}
|
||||
|
@ -1,56 +1,161 @@
|
||||
import { Product } from "@/lib/product"
|
||||
import { Id, Product, dimensions_t } from "@/lib/product"
|
||||
import { useState } from "react"
|
||||
import { FlatList, StyleSheet, Text, TouchableHighlight, View } from "react-native"
|
||||
import {ProductAttributeEditor} from "./ProductAttributeEditor";
|
||||
import React from "react";
|
||||
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
|
||||
import { ProductAttributeEditor } from "./ProductAttributeEditor";
|
||||
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 +163,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",
|
||||
},
|
||||
})
|
46
components/ProductList.tsx
Normal file
46
components/ProductList.tsx
Normal 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,
|
||||
},
|
||||
|
||||
})
|
@ -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: {
|
||||
}
|
||||
}),
|
||||
}
|
49
components/UnitChooser.tsx
Normal file
49
components/UnitChooser.tsx
Normal 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: {
|
||||
|
||||
}
|
||||
})
|
31
components/__tests__/AreaInput-test.tsx
Normal file
31
components/__tests__/AreaInput-test.tsx
Normal 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"
|
||||
});
|
||||
});
|
||||
});
|
18
components/__tests__/MeasurementInput-test.tsx
Normal file
18
components/__tests__/MeasurementInput-test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
@ -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();
|
||||
})
|
||||
|
||||
})
|
72
components/__tests__/ProductCalculatorSelector-test.tsx
Normal file
72
components/__tests__/ProductCalculatorSelector-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
@ -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,
|
||||
const { store } = renderWithProviders(<ProductEditor />, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
29
components/__tests__/UnitChooser-test.tsx
Normal file
29
components/__tests__/UnitChooser-test.tsx
Normal 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
18
eas.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.0.3"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
@ -1,17 +1,36 @@
|
||||
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 { RootState } from '@/app/store';
|
||||
import { classToPlain, plainToClass } from 'class-transformer';
|
||||
|
||||
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({
|
||||
name: 'products-slice',
|
||||
initialState,
|
||||
reducers: {
|
||||
createProduct(state, action: PayloadAction<Product>) {
|
||||
createProduct(state, action: PayloadAction<ProductData>) {
|
||||
if (!state) {
|
||||
return initialState
|
||||
}
|
||||
@ -20,31 +39,133 @@ 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;
|
||||
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,
|
||||
}
|
||||
})
|
||||
};
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
@ -52,8 +173,13 @@ export const actions = {
|
||||
|
||||
export const {
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
changeKey,
|
||||
updateAttribute,
|
||||
addAttribute,
|
||||
deleteAttribute,
|
||||
updatePrice,
|
||||
updateDimensions,
|
||||
} = productsState.actions;
|
||||
|
||||
export default productsState.reducer;
|
||||
|
@ -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);
|
||||
})
|
||||
});
|
137
lib/product.ts
137
lib/product.ts
@ -1,5 +1,6 @@
|
||||
import uuid from "react-native-uuid";
|
||||
import convert, { Area, Length } from "convert";
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
export type Id = string;
|
||||
|
||||
@ -14,18 +15,6 @@ export type ProductAttributes = {
|
||||
currency?: Currency,
|
||||
// [index:string]: any,
|
||||
}
|
||||
|
||||
export type ProductData = {
|
||||
id?: Id,
|
||||
pricePerUnit: number,
|
||||
measurement: {
|
||||
unit: string,
|
||||
value: number,
|
||||
dimension: number,
|
||||
},
|
||||
attributes?: ProductAttributes,
|
||||
};
|
||||
|
||||
export type length_t = {
|
||||
l: number, u: Length
|
||||
}
|
||||
@ -36,55 +25,77 @@ export type 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 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), }
|
||||
: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function dimensionArea(d: dimensions_t) {
|
||||
return "w" in d ? d.w * d.l : 0;
|
||||
}
|
||||
|
||||
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 +105,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,
|
||||
)
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import { RenderOptions, render } from "@testing-library/react-native";
|
||||
import { PropsWithChildren, ReactElement } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { setupStore, RootState } from "@/app/store";
|
||||
import { Product } from "@/lib/product";
|
||||
import { Product, ProductData } from "@/lib/product";
|
||||
|
||||
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
|
||||
preloadedState?: Partial<RootState>;
|
||||
@ -12,7 +12,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
0
lib/util.ts
Normal file
18022
package-lock.json
generated
18022
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -5,9 +5,9 @@
|
||||
"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"
|
||||
},
|
||||
@ -15,15 +15,16 @@
|
||||
"@babel/runtime": "^7.24.7",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@reduxjs/toolkit": "^2.2.6",
|
||||
"@testing-library/react-native": "^12.5.1",
|
||||
"@types/js-quantities": "^1.6.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"convert": "^5.3.0",
|
||||
"enheter": "^1.0.27",
|
||||
"esm": "link:@types/js-quantities/esm",
|
||||
"expo": "~51.0.16",
|
||||
"expo": "~51.0.17",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-font": "~12.0.7",
|
||||
"expo-linking": "~6.3.1",
|
||||
@ -32,19 +33,22 @@
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.6",
|
||||
"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-element-dropdown": "^2.12.1",
|
||||
"react-native-flex-grid": "^1.0.4",
|
||||
"react-native-gesture-handler": "~2.16.2",
|
||||
"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-redux": "^9.1.2",
|
||||
"redux-remember": "^5.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"safe-units": "^2.0.1",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
@ -57,6 +61,7 @@
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"eas-cli": "^10.1.0",
|
||||
"enzyme-adapter-react-15": "^1.4.4",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"jest": "^29.7.0",
|
||||
|
4086
pnpm-lock.yaml
4086
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user