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
|
# 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
|
||||||
|
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).
|
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 = [
|
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"}),
|
||||||
];
|
];
|
11
app.json
11
app.json
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,43 @@
|
|||||||
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 (
|
||||||
<Tabs
|
<Provider store={store}>
|
||||||
screenOptions={{
|
<Tabs
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
screenOptions={{
|
||||||
headerShown: false,
|
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||||
}}>
|
headerShown: false,
|
||||||
<Tabs.Screen
|
}}>
|
||||||
name="index"
|
<Tabs.Screen
|
||||||
options={{
|
name="index"
|
||||||
title: 'Conversion',
|
options={{
|
||||||
tabBarIcon: ({ color, focused }) => (
|
title: 'Home Screen',
|
||||||
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
tabBarIcon: ({ color, focused }) => (
|
||||||
),
|
<TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
|
||||||
}}
|
),
|
||||||
/>
|
}}
|
||||||
<Tabs.Screen
|
/>
|
||||||
name="product-editor"
|
<Tabs.Screen
|
||||||
options={{
|
name="product-editor"
|
||||||
title: 'Products',
|
options={{
|
||||||
tabBarIcon: ({ color, focused }) => (
|
title: 'Products',
|
||||||
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
tabBarIcon: ({ color, focused }) => (
|
||||||
),
|
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
|
||||||
}}
|
),
|
||||||
/>
|
}}
|
||||||
</Tabs>
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@ -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() {
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
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 { 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
|
||||||
|
}
|
||||||
});
|
});
|
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 { 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: {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
<TouchableHighlight
|
<View style={styles.productListHeader}>
|
||||||
onPress={() => setShowAttributes(!showAttributes)}
|
<TouchableHighlight
|
||||||
aria-label="Product Item"
|
onPress={() => setShowAttributes(!showAttributes)}
|
||||||
>
|
aria-label="Product Item"
|
||||||
<Text style={styles.product}>{product.attributes.name || `Product ${product.id}`} </Text>
|
style={styles.productItemName}
|
||||||
</TouchableHighlight>
|
>
|
||||||
|
<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 &&
|
{showAttributes &&
|
||||||
(
|
(
|
||||||
<FlatList
|
<View style={styles.detailsWrapper}>
|
||||||
data={product.attributesAsList}
|
<View style={styles.priceSpecWrapper}>
|
||||||
renderItem={({ item }) => (
|
<Text style={styles.priceLabel}>$</Text>
|
||||||
<ProductAttributeEditor
|
<TextInput inputMode="decimal"
|
||||||
product={product}
|
defaultValue={new String(product.pricePerUnit) as string}
|
||||||
attributeKey={item.key || "some key"}
|
aria-label="price per unit"
|
||||||
attributeValue={item.value}
|
onChangeText={onPricePerUnitChange}
|
||||||
onChange={onAttributeChanged}
|
style={styles.priceInput}
|
||||||
onDelete={onAttributeDelete}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Text style={styles.per}>per</Text>
|
||||||
keyExtractor={(item) => `${product.id}-${item.key}`}
|
<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
|
||||||
|
style={styles.productAttributesList}
|
||||||
|
data={Object.entries(product.attributes)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ProductAttributeEditor
|
||||||
|
attributeKey={item[0] || "some key"}
|
||||||
|
attributeValue={item[1]}
|
||||||
|
onChangeAttributeKey={onAttributeKeyChanged}
|
||||||
|
onChangeAttribute={onAttributeChanged}
|
||||||
|
onDelete={onAttributeDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
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",
|
||||||
|
},
|
||||||
})
|
})
|
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 { 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,
|
||||||
text: {
|
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";
|
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();
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
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 { 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
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 { 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 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;
|
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;
|
||||||
|
@ -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);
|
||||||
|
})
|
||||||
});
|
});
|
143
lib/product.ts
143
lib/product.ts
@ -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 class Product {
|
export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
||||||
public id: string;
|
if (!
|
||||||
public area?: area_t;
|
(
|
||||||
public length?: length_t;
|
(isArea(d1) && isArea(d2)) ||
|
||||||
public presentUnits: Length;
|
(isLength(d1) && isLength(d2))
|
||||||
|
)
|
||||||
constructor(public pricePerUnit: number, dimensions: dimensions_t, public attributes: ProductAttributes = {},) {
|
) {
|
||||||
this.id = attributes.id || uuid.v4().toString();
|
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
||||||
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 priceFor(dimensions: dimensions_t): number {
|
return {
|
||||||
if (this.area && "w" in dimensions) {
|
l: convert(d1.l, d1.u).to(d2.u),
|
||||||
const thisA = this.area.l * this.area.w;
|
u: d2.u,
|
||||||
const otherA = convert(
|
...(
|
||||||
dimensions.w,
|
"w" in d1 ?
|
||||||
dimensions.u
|
{ w: convert(d1.w, d1.u).to(d2.u), }
|
||||||
).to("meter") * convert(
|
: {}
|
||||||
dimensions.l,
|
)
|
||||||
dimensions.u
|
}
|
||||||
).to("meter");
|
}
|
||||||
return (otherA / thisA) * this.pricePerUnit;
|
|
||||||
} if (this.length) {
|
export function dimensionArea(d: dimensions_t) {
|
||||||
const thisL = this.length.l;
|
return "w" in d ? d.w * d.l : 0;
|
||||||
const otherL = convert(
|
}
|
||||||
dimensions.l,
|
|
||||||
dimensions.u
|
export class Product {
|
||||||
).to("meter");
|
|
||||||
return (otherL / thisL) * this.pricePerUnit;
|
public id?: Id;
|
||||||
}
|
|
||||||
throw new Error(`Invalid dimensions: ${dimensions}`);
|
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
|
||||||
|
id?: Id,
|
||||||
|
) {
|
||||||
|
this.id = id || uuid.v4().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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
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
23
package.json
23
package.json
@ -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",
|
||||||
@ -66,4 +71,4 @@
|
|||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
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