start to refactor components.

This commit is contained in:
Jordan 2024-06-30 19:49:41 -07:00
parent 408a996fe7
commit fb68beb1b3
12 changed files with 421 additions and 300 deletions

View File

@ -1,202 +1,13 @@
import { Image, StyleSheet, Platform, ImageBackground, View, Text, Button, TextInputKeyPressEventData, TextInput, FlatList } from 'react-native'; import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { SafeAreaView, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppSelector } from '../store';
import { selectProducts } from '@/features/product/productSlice';
import { Product, dimensions_t } from '@/lib/product';
import { useEffect, useState } from 'react';
import { TouchableHighlight } from 'react-native-gesture-handler';
const fallbackImage = require("@/assets/images/board-stock-lightened-blurred.png"); const fallbackImage = require("@/assets/images/board-stock-lightened-blurred.png");
export default function HomeScreen() { export const HomeScreen = () => {
const products = useAppSelector(selectProducts);
const [activeProduct, setActiveProduct] = useState(null as Product | null);
const [price, setPrice] = useState("0.00");
const [length, setLength] = useState("0");
const [width, setWidth] = useState("0");
const [units, setUnits] = useState("in" as "ft" | "in");
useEffect(function () {
const iv = setInterval(function () {
if (!activeProduct) return;
const l = Number.parseInt(length);
const w = Number.parseInt(width);
// console.log("l=%d, w=%d", l, w);
const u = units;
const d: dimensions_t = activeProduct.area ? { l, w, u } : { l, u };
try {
const p = activeProduct.priceFor(d);
console.log("set price %s", p);
const s = p.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
setPrice(s == "NaN" ? "0.00" : s);
} catch (err) {
console.log(activeProduct);
console.error(err)
return null;
}
}, 50);
return function () {
clearInterval(iv);
}
}, [activeProduct, length, width]);
return ( return (
<SafeAreaView style={styles.wrapper}> <SafeAreaView>
<View style={styles.bigPriceWrapper}> <ProductCalculatorSelector />
<Text style={styles.bigPrice}>$ {price}</Text>
</View>
<View style={styles.inputAndUnitWrapper}>
<View style={styles.inputWrapper}>
{activeProduct ? (
<View>
<TextInput
clearTextOnFocus={true}
onChangeText={setLength}
inputMode='decimal'
style={styles.lengthInput}
/>
<Text style={styles.unitHints}>{units}</Text>
{activeProduct.area && (
<View>
<TextInput
clearTextOnFocus={true}
defaultValue={width}
onChangeText={setWidth}
inputMode='decimal'
style={styles.widthInput}
/>
<Text style={styles.unitHints}>{units}</Text>
</View>)
}
<View style={styles.unitSelector}>
<Button title="in" onPress={() => setUnits("in")} color={units === "in" ? "gray" : "blue"} />
<Button title="ft" onPress={() => setUnits("ft")} color={units === "ft" ? "gray" : "blue"} />
</View>
</View>
) : (<Text>Please choose a product</Text>)}
</View>
</View>
<FlatList
data={products}
style={styles.productSelectorFlatList}
renderItem={({ item }) => {
return (
<TouchableHighlight
style={item === activeProduct ? styles.productTileTouchableActive : styles.productTileTouchable}
onPress={() => setActiveProduct(item)}>
<Text style={item === activeProduct ? styles.productTileTextActive : styles.productTileText}>{item.attributes.name || `Product ${item.id}`}</Text>
</TouchableHighlight>
)
}}
/>
</SafeAreaView> </SafeAreaView>
); )
} }
const styles = StyleSheet.create({
wrapper: {
},
bigPriceWrapper: {
alignContent: "center",
},
bigPrice: {
alignSelf: "center",
fontSize: 40,
marginTop: 100,
marginBottom: 100,
},
inputWrapper: {
flexDirection: "row",
alignItems: "flex-start",
},
unitSelector: {
},
inputAndUnitWrapper: {
flexDirection: "row",
alignSelf: "center",
},
unitHints: {
fontSize: 30,
padding: 10,
},
lengthInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 30,
width: 200,
},
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,
},
productSelectorFlatList: {
padding: 10,
margin: 10,
},
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,
},
});

49
components/AreaInput.tsx Normal file
View File

@ -0,0 +1,49 @@
import { View } from "react-native-reanimated/lib/typescript/Animated";
import { MeasurementInput } from "./MeasurementInput";
import { area_t, dimensions_t } from "@/lib/product";
import { useState } from "react";
export type AreaInputProps = {
units: "foot" | "inch",
onMeasurementSet?: (area : dimensions_t) => any,
}
export function AreaInput({units, onMeasurementSet} : AreaInputProps) {
const [area, setArea] = useState({
l: 0,
w: 0,
u: "foot",
} as area_t)
function doOnLengthSet(measurement : dimensions_t) {
setArea({
...area,
l: measurement.l
});
onMeasurementSet && onMeasurementSet(area);
}
function doOnWidthSet(measurement : dimensions_t) {
setArea({
...area,
w: measurement.l
});
onMeasurementSet && onMeasurementSet(area);
}
return (
<View>
<MeasurementInput
units={units}
defaultValue={area.l}
onValueSet={doOnLengthSet}
/>
<MeasurementInput
units={units}
defaultValue={area.w}
onValueSet={doOnWidthSet}
/>
</View>
)
}

View File

@ -1,88 +0,0 @@
import { dimensions_t, length_t } from "@/lib/product";
import { Length } from "convert";
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?: (d : dimensions_t) => 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 Length);
function doSetLength(text: string) {
const value = parseFloat(text);
setLength(value);
if (!props.isArea) {
const len = en_length(unit, value)
props.onMeasurementSet && props.onMeasurementSet(len)
} else {
const en_unit = unit == "foot" ? "squareFoot" : "squareInch"
const ar = en_area(en_unit, value);
props.onMeasurementSet && props.onMeasurementSet(ar);
}
}
function doSetWidth(text: string) {
const value = parseFloat(text);
setLength(value);
const len = en_length(unit, value)
props.onMeasurementSet && props.onMeasurementSet(len)
}
return (
<SafeAreaView>
<View style={styles.inputRow}>
<TextInput
keyboardType="number-pad"
onTouchEnd={() => setLength(null)}
value={length?.toString() || ""}
onChangeText={doSetLength}
style={styles.textInput}
/>
{props.isArea &&
(<TextInput
keyboardType="number-pad"
onTouchEnd={() => setWidth(null)}
value={length?.toString() || ""}
onChangeText={doSetWidth}
style={styles.textInput}
/>)
}
<Text style={styles.valueHint}>{unit == "foot" ? "ft" : "in"}</Text>
<Button
title="Ft"
onPress={() => setUnit("foot")}
color={unit === "foot" ? "blue" : "gray"}
/>
<Button
title="In"
onPress={() => setUnit("inch")}
color={unit === "inch" ? "blue" : "gray"}
/>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
textInput: {
flexGrow: 1,
},
inputRow: {
flexDirection: "row"
},
valueHint: {
color: "grey",
margin: 5,
}
})

View File

@ -0,0 +1,48 @@
import { dimensions_t, length_t } from "@/lib/product";
import { StyleSheet, Text, TextInput, View } from "react-native";
export type t_length_unit = "foot" | "inch"
export type MeasurementInputProps = {
onValueSet?: (d: dimensions_t) => any,
units: t_length_unit,
defaultValue: number;
}
export function MeasurementInput({onValueSet, units, defaultValue}: MeasurementInputProps) {
function doOnValueSet(value : string) {
onValueSet && onValueSet({
l: (parseInt(value) || parseFloat(value)),
u: units,
})
}
return (
<View>
<TextInput
clearTextOnFocus={true}
defaultValue={new String(defaultValue).valueOf()}
onChangeText={doOnValueSet}
inputMode='decimal'
style={styles.lengthInput} />
<Text style={styles.unitHints}>{units}</Text>
</View>
)
}
const styles = StyleSheet.create({
unitHints: {
fontSize: 30,
padding: 10,
},
lengthInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 30,
width: 200,
},
})

39
components/Price.tsx Normal file
View File

@ -0,0 +1,39 @@
import { StyleSheet, Text } from "react-native";
import { View } from "react-native-reanimated/lib/typescript/Animated";
export type PriceDisplayProps = {
price: number,
currency?: {
symbol: string,
}
}
export default function PriceDisplay({ price }: PriceDisplayProps) {
return (
<View style={styles.bigPriceWrapper}>
<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: 100,
marginBottom: 100,
}
});

View File

@ -0,0 +1,148 @@
import { useAppSelector } from '@/app/store';
import { selectProducts } from '@/features/product/productSlice';
import { Product, dimensions_t } from '@/lib/product';
import { useState, useEffect } from 'react';
import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native';
import { TouchableHighlight } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import PriceDisplay from './Price';
import { AreaInput } from './AreaInput';
import { MeasurementInput } from './MeasurementInput';
export default function ProductCalculatorSelector() {
const products = useAppSelector(selectProducts);
const [activeProduct, setActiveProduct] = useState(null as Product | null);
const [price, setPrice] = useState(0);
const [measurement, setMeasurement] = useState(null as dimensions_t | null);
useEffect(function () {
const iv = setInterval(function () {
if (!(activeProduct && measurement)) return;
setPrice(
activeProduct.priceFor(measurement)
)
}, 50);
return function () {
clearInterval(iv);
};
}, [activeProduct, measurement]);
function onMeasurementSet(dimensions: dimensions_t) {
setMeasurement(dimensions);
}
return (
<SafeAreaView style={styles.wrapper}>
<PriceDisplay price={price} />
<View style={styles.inputAndUnitWrapper}>
<View style={styles.inputWrapper}>
{
activeProduct ? (
"w" in activeProduct.dimensions ?
<AreaInput
units={units}
onMeasurementSet={onMeasurementSet}
/>
:
<MeasurementInput
defaultValue={activeProduct.dimensions.l}
units={units}
onValueSet={onMeasurementSet}
/>
) : (
<Text>Please select a product</Text>
)
}
</View>
</View>
</SafeAreaView>
);
}
export const styles = StyleSheet.create({
wrapper: {
},
bigPriceWrapper: {
alignContent: "center",
},
bigPrice: {
alignSelf: "center",
fontSize: 40,
marginTop: 100,
marginBottom: 100,
},
inputWrapper: {
flexDirection: "row",
alignItems: "flex-start",
},
unitSelector: {
},
inputAndUnitWrapper: {
flexDirection: "row",
alignSelf: "center",
},
widthInput: {
width: 200,
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 30,
},
activeProduct: {
borderWidth: 2,
borderColor: "black",
borderStyle: "solid",
},
inactiveProduct: {
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
},
productTileTouchable: {
margin: 10,
padding: 20,
backgroundColor: "grey",
},
productTileTouchableActive: {
borderWidth: 2,
borderStyle: "solid",
borderColor: "black",
margin: 10,
padding: 20,
},
productTileText: {
textAlign: "center",
color: "white",
},
productTileTextActive: {
textAlign: "center",
color: "black",
},
productTileCover: {
padding: 4,
},
});

View File

@ -0,0 +1,47 @@
import { FlatList, StyleSheet, Text, TouchableHighlight } from "react-native";
import { ProductTile } from "./ProductTile";
import { Product } from "@/lib/product";
import { useState } from "react";
import { useSelector } from "react-redux";
import { selectProducts } from "@/features/product/productSlice";
export type ProductSelectionProps = {
onProductSelected?: (product : Product) => any;
}
export default function ProductList ({onProductSelected} : ProductSelectionProps) {
const products = useSelector(selectProducts);
const [activeProduct, setActiveProduct] = useState(null as null | Product);
function doOnProductSelected(product : Product) {
setActiveProduct(product);
onProductSelected && onProductSelected(product);
}
return (
<FlatList
data={products}
style={styles.productSelectorFlatList}
renderItem={({ item }) => {
return (
<ProductTile
product={item}
onProductSelected={doOnProductSelected}
isActive={activeProduct === item}
/>
);
} } />
)
}
const styles = StyleSheet.create({
productSelectorFlatList: {
padding: 10,
margin: 10,
},
})

View File

@ -1,5 +1,5 @@
import { Product } from "@/lib/product" import { Product } from "@/lib/product"
import { ImageBackground, StyleProp, StyleSheet, Text, View, 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";
export type OnProductSelectedFunc = (product : Product) => any; export type OnProductSelectedFunc = (product : Product) => any;
@ -9,31 +9,42 @@ type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
export type ProductTileProps = { export type ProductTileProps = {
product: (Product), product: (Product),
onProductSelected?: OnProductSelectedFunc, onProductSelected?: OnProductSelectedFunc,
isActive: boolean,
style?: { style?: {
tile?: MyStyle, default?: {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle, image?: MyStyle,
} }
active?: {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
}
} }
const FALLBACK_IMAGE = ""; const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, style} : ProductTileProps) { export function ProductTile ({product, onProductSelected, isActive, style} : ProductTileProps) {
const src = product.attributes.image || FALLBACK_IMAGE; const _style = (isActive ? style?.active : style?.default) || {};
return ( return (
<View style={styles.tile}>
<ImageBackground <TouchableHighlight
src={src} style={_style.highlight || styles.highlight}
resizeMode="cover" onPress={() => onProductSelected && onProductSelected(product)}>
style={styles.image} <Text style={_style.text || styles.text}>
> {product.attributes.name || `Product ${product.id}`}
<Text style={styles.text}>{product.attributes.name || `Product ${product.id}`}</Text> ({product.pricePerUnitDisplay})
<Text style={styles.text}>{ product.priceDisplay } </Text> </Text>
</ImageBackground> </TouchableHighlight>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
highlight: {
},
image: { image: {
}, },

View File

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

View File

@ -147,7 +147,7 @@ const productsState = createSlice({
}); });
export const selectProducts = (state: RootState) => { export const selectProducts = (state: RootState) => {
return state.products.map(Product.fromObject); return state.products.map(obj => Product.fromObject(obj));
} }
export const selectProductIds = createSelector([selectProducts], products => { export const selectProductIds = createSelector([selectProducts], products => {

View File

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

View File

@ -117,7 +117,7 @@ export class Product {
return { return {
id: this.id, id: this.id,
pricePerUnit: this.pricePerUnit, pricePerUnit: this.pricePerUnit,
dimensions: this.dimensions, dimensions: JSON.parse(JSON.stringify(this.dimensions)),
attributes: this.attributes, attributes: this.attributes,
} }
} }