Compare commits

23 Commits

Author SHA1 Message Date
d762a8f70f clean up raw svg. give up on dark theme switcher. ready for another release. 2024-08-19 07:58:36 -07:00
257642a251 fix layout of carpet roll calculator. todo: reinstate unit buttons for plywood calculator. 2024-08-19 07:08:54 -07:00
dc7f4b25a9 add svg icons. running into scroll issue. will upgrade packages. 2024-08-15 14:07:19 -07:00
a463189052 add area carpet fixture. Add carpet roll calculator test. refactor carpet roll as own component. add icons. 2024-08-10 10:06:25 -07:00
dbba262044 the type construct of components display. TODO: add area rug calculator. 2024-07-31 10:01:45 -07:00
23d957824b Merge branch 'develop-product-as-type' into develop 2024-07-17 06:55:49 -07:00
2bd5566d6a add area rug test. merge with product as type. 2024-07-17 06:55:41 -07:00
d2368b30e5 refactor products as type. 2024-07-17 06:54:55 -07:00
49266bbc97 work on area rug functions. 2024-07-12 06:14:14 -07:00
f6a151337a add screenshots and images for docs. add icon. 2024-07-05 15:00:08 -07:00
ce826bd8db solve npm issues. got a successful apk build. 2024-07-04 08:29:47 -07:00
fe927b44ad did some more cleanup of the interface and UI interactions. 2024-07-02 08:34:57 -07:00
bf3923b4b9 did some more cleanup of the interface and UI interactions. 2024-07-02 08:34:23 -07:00
466e005e4e pushing to remote repo. 2024-07-02 07:00:27 -07:00
7076d33287 successful aes build. 2024-07-02 06:59:51 -07:00
012fd77a10 start eas build. 2024-07-01 13:01:25 -07:00
ecdc9db085 good enough for government (or habitat) work 2024-07-01 12:23:45 -07:00
379f43dcd9 complete more of the unit tests. 2024-07-01 08:05:24 -07:00
76fe4eb34a Generated component unittests. 2024-07-01 06:15:43 -07:00
fb68beb1b3 start to refactor components. 2024-06-30 19:49:41 -07:00
408a996fe7 change product to dump to object before storing in redix. TODO: solve dimensions issue. 2024-06-30 09:37:27 -07:00
de0167e9e5 made it look prettier. working on fixing product editor. 2024-06-29 06:09:22 -07:00
7c2289098e working on getting input to respond correctly. 2024-06-28 17:04:30 -07:00
74 changed files with 6400 additions and 22731 deletions

6
.gitignore vendored
View File

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

124
README.md
View File

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

View File

@ -1,12 +1,113 @@
import { Product } from "@/lib/product";
import { Product } from "@/lib/product"
import uuid from "react-native-uuid"
export const products = [
// Sheet goods
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }),
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }),
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }),
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }),
// Beams and trim
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }),
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }),
];
export default [
// Sheet goods
{
id: uuid.v4().valueOf(),
pricePerUnit: 15,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: 'Plywood 1/4"' },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 20,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: 'Plywood 1/2"' },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 25,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: 'Plywood 3/4"' },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 5,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Thin Panel Board" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 10,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Sheetrock" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 15,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "OSB / Particle" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 20,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "MDF" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 15,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Pegboard" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 5,
dimensions: { l: 3, w: 5, u: "ft" },
type: "lumber",
attributes: { name: "Cement" },
},
// trim
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 0.5, u: "ft" },
type: "lumber",
attributes: { name: "trim <=3 inches" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 0.75, u: "ft" },
type: "lumber",
attributes: { name: "trim > 3 inches" },
},
// siding
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "house siding" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "metal / shelf bars" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 0.5,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "gutter spouts" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 0.75,
dimensions: { l: 1, w: 1, u: "ft" },
type: "area_rug",
attributes: { name: "area rug" },
},
] as Array<Product>;

View File

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

View File

@ -1,37 +1,70 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { Tabs } from "expo-router";
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { SvgUri } from "react-native-svg";
import { Colors } from "@/constants/Colors";
import { useColorScheme } from "@/hooks/useColorScheme";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Provider } from "react-redux";
import fixtures from "@/__fixtures__/initialProducts";
import { setupStore } from "../store";
const CARPET_ROLL_SVG = "/assets/images/icons/icon-carpet-roll-raw.svg";
const CARPET_ROLL_SELECTED_SVG =
"/assets/images/icons/icon-carpet-roll-selected-raw.svg";
const CarpetRollIcon = ({ selected }: { selected: boolean }) => {
const uri = selected ? CARPET_ROLL_SELECTED_SVG : CARPET_ROLL_SVG;
console.log(`Loading %s`, uri);
return <SvgUri width="2em" height="2em" uri={uri} />;
};
export default function TabLayout() {
const colorScheme = useColorScheme();
const store = setupStore({
products: fixtures,
});
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Conversion',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
),
<Provider store={store}>
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: false,
}}
/>
<Tabs.Screen
name="product-editor"
options={{
title: 'Products',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
),
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Plywood",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "scale" : "scale-outline"}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="carpet-roll-calculator"
options={{
title: "Carpet Roll Calculator",
tabBarIcon: ({ color, focused }) => (
<CarpetRollIcon selected={focused} />
),
}}
/>
<Tabs.Screen
name="product-editor"
options={{
title: "Edit Products",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "list" : "list-outline"}
color={color}
/>
),
}}
/>
</Tabs>
</Provider>
);
}

View File

@ -0,0 +1,10 @@
import CarpetRollCalculator from '@/components/CarpetRollCalculator';
import { View } from 'react-native';
export default function CarpetRollCalculatorView () {
return (
<View>
<CarpetRollCalculator />
</View>
)
}

View File

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

View File

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

View File

@ -1,11 +1,19 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import "react-native-reanimated";
import "react-native-gesture-handler";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, useState } from "react";
import { useColorScheme } from '@/hooks/useColorScheme';
import { useColorScheme } from "@/hooks/useColorScheme";
import { Appearance, StyleSheet, Text, Pressable } from "react-native";
const isBrowser = typeof window !== "undefined";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@ -13,9 +21,18 @@ SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
function changeTheme() {
console.debug("Changing color scheme");
if (Appearance.getColorScheme() === "dark") {
Appearance.setColorScheme("light");
} else {
Appearance.setColorScheme("dark");
}
}
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
@ -27,11 +44,11 @@ export default function RootLayout() {
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
);
}
}

23
app/app.tsx Normal file
View File

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

View File

@ -1,35 +1,57 @@
import { useDispatch, useSelector } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { rememberReducer, rememberEnhancer } from 'redux-remember';
import reducers from "@/features/product/productSlice"
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, } from "@/lib/product";
import { useDispatch, useSelector } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { rememberReducer, rememberEnhancer } from "redux-remember";
import reducers, { DEFAULT_PRELOADED_STATE } from "@/features/product/productSlice";
import AsyncStorage from "@react-native-async-storage/async-storage";
const rememberedKeys = ['products'];
// thanks to https://github.com/rt2zz/redux-persist/issues/1208#issuecomment-658695446
// for the workaround
const createNoopStorage = () => {
return {
getItem(_key : any) {
return Promise.resolve(null);
},
setItem(_key : any, value : any) {
return Promise.resolve(value);
},
removeItem(_key : any) {
return Promise.resolve();
},
};
};
const storage =
typeof window === "undefined" ? createNoopStorage() : AsyncStorage;
export default storage;
const PERSIST_WHOLE_STORE =
new Boolean(process.env.PERSIST_WHOLE_STORE).valueOf() || false;
const rememberedKeys = ["products"];
const rootReducer = reducers;
export function setupStore(preloadedState = {
products: [] as Product[],
}) {
return configureStore({
reducer: rememberReducer(reducers),
preloadedState,
enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat(
rememberEnhancer(
AsyncStorage,
rememberedKeys,
{
persistWholeStore: true,
}
)
),
});
// const isBrowser = typeof window !== "undefined";
export function setupStore(preloadedState = DEFAULT_PRELOADED_STATE) {
return configureStore({
reducer: rememberReducer(reducers),
preloadedState,
enhancers: (getDefaultEnhancers) =>
getDefaultEnhancers().concat(
rememberEnhancer(storage, rememberedKeys, {
persistWholeStore: PERSIST_WHOLE_STORE,
})
),
});
}
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch'];
export type AppDispatch = AppStore["dispatch"];
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5">
<defs
id="defs2" />
<g
id="layer1"
transform="translate(-45.575391,42.027803)">
<path
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
id="path2530"
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:19.4423;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path2584"
cx="145.57539"
cy="57.972198"
r="37.746468" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="carpet-roll-length-inner-diameter.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.56504558"
inkscape:cx="16.812803"
inkscape:cy="315.90372"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-45.022935,37.596851)" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5">
<defs
id="defs2" />
<g
id="layer1"
transform="translate(-45.575391,42.027803)">
<path
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
id="path2530"
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path2733"
cx="98.956039"
cy="63.796986"
r="10.301529" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="circle2839"
cx="77.884727"
cy="63.796986"
r="10.301529" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="circle2841"
cx="56.813419"
cy="63.796986"
r="10.301529" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="carpet-roll-length-number-of-rings.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.56504558"
inkscape:cx="15.043034"
inkscape:cy="331.83164"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-45.575391,42.027803)">
<path
sodipodi:type="spiral"
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
id="path2530"
sodipodi:cx="377.95276"
sodipodi:cy="377.95276"
sodipodi:expansion="0.75"
sodipodi:revolution="4.1178799"
sodipodi:radius="386.66254"
sodipodi:argument="-20.113815"
sodipodi:t0="0.29170668"
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path2733"
cx="98.956039"
cy="63.796986"
r="10.301529" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="circle2839"
cx="77.884727"
cy="63.796986"
r="10.301529" />
<circle
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="circle2841"
cx="56.813419"
cy="63.796986"
r="10.301529" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5">
<defs
id="defs2" />
<g
id="layer1"
transform="translate(-45.575391,42.027803)">
<path
style="fill:none;fill-rule:evenodd;stroke:#ff0000;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
id="path2530"
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="carpet-roll-length-outer-diameter.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.56504558"
inkscape:cx="15.043034"
inkscape:cy="331.83164"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-45.575391,42.027803)">
<path
sodipodi:type="spiral"
style="fill:none;fill-rule:evenodd;stroke:#ff0000;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
id="path2530"
sodipodi:cx="377.95276"
sodipodi:cy="377.95276"
sodipodi:expansion="0.75"
sodipodi:revolution="4.1178799"
sodipodi:radius="386.66254"
sodipodi:argument="-20.113815"
sodipodi:t0="0.29170668"
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5">
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-45.022935,37.596851)">
<g
id="g2358"
transform="translate(9.9331103,33.633936)"
style="stroke-width:1.000125;stroke-dasharray:none;stroke:#4d4d4d">
<path
id="path2242"
style="fill:none;fill-opacity:1;stroke:#4d4d4d;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 61.405087,24.188362 95.383293,-52.476396 c 24.96471,-6.689275 40.50388,-5.439089 53.53212,6.973747 13.06695,12.4497209 16.56131,25.0292929 9.58226,53.29021 l -79.61121,74.744307">
<path
style="fill:none;fill-opacity:1;stroke:#4d4d4d;stroke-width:10.58333333;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 61.405087,24.188362 c 33.653,-20.0428233 86.037773,3.774406 93.118353,36.785838 7.08285,33.022041 -22.22503,60.93414 -48.54578,62.44 C 78.791137,124.96959 39.251217,95.823248 53.111058,61.74891 66.84398,27.986602 114.30936,21.282096 133.47145,61.067637 141.69618,78.144337 124.82498,115.7347 93.694884,101.95457 67.187624,90.220797 80.85949,55.619126 103.5068,72.272096"
id="path2296" />
</g>
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 54.956029,14.247787 v 49.11798"
id="path2361" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:7.933;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 165.74785,-31.776353 V -4.1786544"
id="path2363" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 54.956029,38.806777 165.74785,-17.977504"
id="path2365" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 66.655684,60.631804 166.72149,5.3459019"
id="path2419" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="carpet-roll-length.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.56504558"
inkscape:cx="16.812803"
inkscape:cy="315.90372"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-45.022935,37.596851)">
<g
id="g2358"
transform="translate(9.9331103,33.633936)"
style="stroke-width:1.000125;stroke-dasharray:none">
<path
id="path2242"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 61.405087,24.188362 95.383293,-52.476396 c 24.96471,-6.689275 40.50388,-5.439089 53.53212,6.973747 13.06695,12.4497209 16.56131,25.0292929 9.58226,53.29021 l -79.61121,74.744307"
sodipodi:nodetypes="ccscc" />
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:10.58333333;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 61.405087,24.188362 c 33.653,-20.0428233 86.037773,3.774406 93.118353,36.785838 7.08285,33.022041 -22.22503,60.93414 -48.54578,62.44 C 78.791137,124.96959 39.251217,95.823248 53.111058,61.74891 66.84398,27.986602 114.30936,21.282096 133.47145,61.067637 141.69618,78.144337 124.82498,115.7347 93.694884,101.95457 67.187624,90.220797 80.85949,55.619126 103.5068,72.272096"
id="path2296"
sodipodi:nodetypes="csssssc" />
</g>
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 54.956029,14.247787 v 49.11798"
id="path2361" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:7.933;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 165.74785,-31.776353 V -4.1786544"
id="path2363" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 54.956029,38.806777 165.74785,-17.977504"
id="path2365"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 66.655684,60.631804 166.72149,5.3459019"
id="path2419"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="138.30681mm"
height="154.33244mm"
viewBox="0 0 138.30681 154.33244"
version="1.1"
id="svg5">
<defs
id="defs2" />
<g
id="layer1"
transform="translate(-16.21663,9.4574458)">
<path
id="path1159"
style="color:#000000;fill:#4d4d4d;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,15 @@
<svg
width="138.30681mm"
height="154.33244mm"
viewBox="0 0 138.30681 154.33244">
<defs
id="defs2" />
<g
id="layer1"
transform="translate(-16.21663,9.4574458)">
<path
id="path1159"
style="color:#000000;fill:#0a7ea4;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,17 @@
<svg
width="142.37779mm"
height="154.51445mm"
viewBox="0 0 142.37779 154.51445">
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-16.165914,9.5264309)">
<path
id="path1159"
style="color:#000000;fill:#666666;fill-rule:evenodd;stroke:#cccccc;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 85.203125,-7.3242188 C 69.920576,-6.8219072 54.978371,-0.73159347 43.488281,9.3105469 29.730781,21.167938 20.62376,38.325311 18.755859,56.416016 c -1.734796,15.95712 1.938409,32.407948 10.191407,46.164064 10.334605,17.47423 27.516469,30.69674 46.990234,36.375 6.645281,1.95148 13.56116,3.1467 20.517578,3.0625 19.153082,0.25684 38.305822,0.51598 57.458982,0.77148 a 0.33113676,0.33113676 0 0 0 0.33399,-0.30664 c 0.64021,-8.0547 1.28097,-16.1094 1.92187,-24.16406 a 0.33113676,0.33113676 0 0 0 -0.32617,-0.35742 c -20.65485,-0.286 -41.30981,-0.51288 -61.960938,-0.86719 h -0.0039 c -13.133341,-1.00893 -25.787797,-7.10439 -35.0625,-16.4375 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 52.343603,94.184995 47.495562,86.16238 45.046875,77.314453 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 42.922997,69.749353 42.661672,61.651864 44.335938,54.025391 47.301902,40.407665 56.579386,28.264581 69.050781,22.005859 a 0.33113676,0.33113676 0 0 0 0.002,-0.002 c 7.218526,-3.661983 15.535683,-5.174225 23.513672,-4.125 a 0.33113676,0.33113676 0 0 0 0.002,0 c 12.749801,1.558443 24.475741,10.023864 29.830081,21.716797 1.528,3.328833 2.54287,7.075443 2.8457,10.787109 a 0.33113676,0.33113676 0 0 0 0,0.0039 c 0.50314,5.427095 -0.52104,11.148138 -3.02734,16.058593 a 0.33113676,0.33113676 0 0 0 -0.002,0.002 c -3.77296,7.555238 -10.91161,13.615485 -19.2832,15.21289 a 0.33113676,0.33113676 0 0 0 0,0.002 c -4.546338,0.882115 -9.418457,0.367934 -13.595702,-1.726562 a 0.33113676,0.33113676 0 0 0 -0.0039,-0.002 c -5.431125,-2.626837 -9.860013,-7.754966 -10.794922,-13.78711 -0.497265,-3.192735 0.282359,-6.536996 2.125,-9.185546 -0.0139,2.705038 0.257235,5.460018 1.404297,7.986328 1.724833,4.159718 5.336057,7.559506 9.757813,8.675781 3.964246,1.059122 8.116269,0.326271 11.890621,-0.886719 a 0.33113676,0.33113676 0 0 0 0.002,0 c 2.75294,-0.915515 5.13542,-2.694581 6.95508,-4.916016 l 0.004,-0.0059 c 4.22887,-4.970422 5.92559,-11.850563 4.76367,-18.238281 v -0.0039 c -1.39169,-8.320828 -7.31354,-15.475053 -14.83007,-19.078125 l -0.002,-0.002 C 92.910555,26.731743 83.676017,26.736335 75.808594,29.951172 64.535605,34.499437 56.212806,45.226465 54.117188,57.134766 c -1.968025,10.346436 0.517884,21.379932 6.552734,29.984375 7.700829,11.262849 20.653527,18.702169 34.257812,19.705079 11.668956,0.98802 23.584106,-2.53175 32.964846,-9.503908 l 0.002,-0.002 c 12.41469,-9.061773 20.69784,-23.548746 22.11133,-38.865234 v -0.002 c 1.33798,-13.328328 -2.27053,-27.040971 -9.87305,-38.054688 v -0.002 C 130.49093,6.1822132 114.86084,-3.7880615 97.898438,-6.5078125 93.705473,-7.195958 89.449462,-7.4542424 85.205078,-7.3242188 a 0.33113676,0.33113676 0 0 0 -0.002,0 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="142.37779mm"
height="154.51445mm"
viewBox="0 0 142.37779 154.51445"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="icon-carpet-roll.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#585858"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.79909512"
inkscape:cx="428.6098"
inkscape:cy="198.34935"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-16.165914,9.5264309)">
<path
id="path1159"
style="color:#000000;fill:#333333;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

78
components/AreaInput.tsx Normal file
View File

@ -0,0 +1,78 @@
import { MeasurementInput } from "./MeasurementInput";
import { area_t, dimensions_t } from "@/lib/dimensions";
import { Length } from "convert";
import { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import MeasurementUnitInput from "./MeasurementUnitInput";
import { useAppDispatch, useAppSelector } from "@/app/store";
import { selectPlywoodCalc } from "@/features/product/productSlice";
export type AreaInputProps = {
onMeasurementSet?: (area : dimensions_t) => any,
defaultValue?: area_t,
lengthLabel?: string,
widthLabel?: string,
units?: Length,
}
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue, units} : AreaInputProps) {
defaultValue = defaultValue || {l: 0, w: 0, u: "ft"}
units = units || "ft"
const [area, setArea] = useState(defaultValue)
function doOnLengthSet(l: number) {
const a : area_t = { ...area, l };
setArea(a);
onMeasurementSet && onMeasurementSet(a);
}
function doOnLengthUnitSet(u: Length) {
const a : area_t = { ...area, u };
setArea(a);
onMeasurementSet && onMeasurementSet(a);
}
function doOnWidthSet(l: number) {
const a : area_t = { ...area, l };
setArea(a);
onMeasurementSet && onMeasurementSet(a);
}
function doOnWidthUnitSet(u: Length) {
const a : area_t = { ...area, u };
setArea(a);
onMeasurementSet && onMeasurementSet(a);
}
return (
<View style={styles.areaInputWrapper}>
<MeasurementUnitInput
label="Length"
defaultValue={0}
defaultUnit={units}
onValueSet={doOnLengthSet}
onUnitSet={doOnLengthUnitSet}
aria-label="length"
/>
<Text style={{fontSize: 30,}} > x </Text>
<MeasurementUnitInput
label="Width"
defaultValue={0}
defaultUnit={units}
onValueSet={doOnWidthSet}
onUnitSet={doOnWidthUnitSet}
aria-label="width"
/>
</View>
)
}
const styles = StyleSheet.create({
areaInputWrapper: {
flexDirection: "row",
verticalAlign: "middle",
}
})

53
components/AreaRugTag.tsx Normal file
View File

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

View File

@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { View, Text, StyleSheet, StatusBar, Dimensions, useWindowDimensions } from "react-native";
import { Product } from "@/lib/product";
import { selectProducts } from "@/features/product/productSlice";
import { area_t, diameterToLength, length_t } from "@/lib/dimensions";
import { useAppSelector } from "../app/store";
import { AreaRugTag } from "@/components/AreaRugTag";
import convert, { Length } from "convert";
import ProductList from "@/components/ProductList";
import { HelpfulMeasurementUnitInput } from "./HelpfulMeasurementInput";
import { ScrollView } from "react-native-gesture-handler";
import { SafeAreaView } from "react-native-safe-area-context";
const DEFAULT_DIAMETER_UNIT: Length = "in";
const DEFAULT_LENGTH_UNIT: Length = "ft";
const screenDimensions = Dimensions.get('screen');
const windowDimensions = Dimensions.get('window');
export const CarpetRollCalculator = () => {
const products = useAppSelector(selectProducts);
const [width, setWidth] = useState(0);
const [outerDiameter, setOuterDiameter] = useState<length_t>({
l: 0,
u: DEFAULT_DIAMETER_UNIT,
});
const [innerDiameter, setInnerDiameter] = useState<length_t>({
l: 0,
u: DEFAULT_DIAMETER_UNIT,
});
const [numRings, setNumRings] = useState(0);
const [rugDimensions, setRugDimensions] = useState<area_t>({
u: DEFAULT_LENGTH_UNIT,
w: 0,
l: 0,
});
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [units, setUnits] = useState<Length>(DEFAULT_LENGTH_UNIT);
useEffect(() => {
// convert the "diameter" units to the length unit.
const outerD2Value = convert(outerDiameter.l, outerDiameter.u).to(units);
const innerD2Value = convert(innerDiameter.l, innerDiameter.u).to(units);
const innerD2 = {
l: innerD2Value,
u: units,
};
const outerD2 = {
l: outerD2Value,
u: units,
};
const l = diameterToLength(outerD2, innerD2, numRings).l;
const dimens = {
l,
w: width || selectedProduct?.dimensions.l || 0.0,
u: units || selectedProduct?.dimensions.u || "ft",
};
console.dir(dimens);
setRugDimensions(dimens);
}, [outerDiameter, innerDiameter, width, numRings, selectedProduct, units]);
return (
<SafeAreaView style={styles.container}>
<View style={styles.placeholder}>
{selectedProduct ? (
<AreaRugTag dimensions={rugDimensions} product={selectedProduct} />
) : (
<Text style={styles.placeholderText}>Please Select a Product</Text>
)}
</View>
<ScrollView style={styles.scrollView}>
<View>
<View style={styles.inputFieldWrapper}>
<HelpfulMeasurementUnitInput
label="Length"
svgUri="/assets/images/icons/carpet-roll-length-raw.svg"
onUnitSet={setUnits}
onValueSet={setWidth}
defaultValue={width}
defaultUnit={units}
unitChoices={["ft", "in"]}
/>
</View>
<View style={styles.inputFieldWrapper}>
<HelpfulMeasurementUnitInput
label="Inner diameter"
svgUri="/assets/images/icons/carpet-roll-length-inner-diameter-raw.svg"
onUnitSet={(u) => setInnerDiameter({ ...innerDiameter, u })}
defaultValue={innerDiameter.l}
defaultUnit={innerDiameter.u}
unitChoices={["ft", "in"]}
onValueSet={(l) => setInnerDiameter({ ...innerDiameter, l })}
/>
</View>
<View style={styles.inputFieldWrapper}>
<HelpfulMeasurementUnitInput
label="Outer diameter"
svgUri="/assets/images/icons/carpet-roll-length-outer-diameter-raw.svg"
onUnitSet={(u) => setOuterDiameter({ ...outerDiameter, u })}
defaultValue={innerDiameter.l}
defaultUnit={innerDiameter.u}
unitChoices={["ft", "in"]}
onValueSet={(l) => setOuterDiameter({ ...outerDiameter, l })}
/>
</View>
<View style={styles.inputFieldWrapper}>
<HelpfulMeasurementUnitInput
label="Number of rings"
svgUri="/assets/images/icons/carpet-roll-length-number-of-rings-raw.svg"
defaultValue={0}
onValueSet={setNumRings}
/>
</View>
</View>
<View>
<ProductList
onProductSelected={setSelectedProduct}
productType="area_rug"
/>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight,
},
scrollView: {
marginHorizontal: 20,
height: windowDimensions.height - 50,
},
placeholder: {
alignContent: "center",
alignSelf: "center",
height: 300,
paddingTop: 40,
paddingBottom: 40,
fontSize: 30,
position: "static",
},
placeholderText: {
fontSize: 30,
paddingVertical: 70,
},
inputFieldWrapper: {
// padding: 10,
},
inputFields: {},
label: {
flex: 1,
flexDirection: "row",
},
numberInput: {
flexDirection: "row",
borderStyle: "solid",
borderColor: "black",
borderWidth: 1,
},
});
export default CarpetRollCalculator;

View File

@ -0,0 +1,33 @@
import { StyleSheet, Text, View } from "react-native";
import { MeasurementInputProps } from "./MeasurementInput";
import MeasurementUnitInput, {
MeasurementUnitInputProps,
} from "./MeasurementUnitInput";
import { SvgUri } from "react-native-svg";
import { Length } from "convert";
export type HelpfulMeasurementUnitInputParams = MeasurementUnitInputProps & {
svgUri: string;
label: string;
unitChoices?: Length[];
};
export function HelpfulMeasurementUnitInput(
props: HelpfulMeasurementUnitInputParams
) {
return (
<View>
<SvgUri uri={props.svgUri} width="100px" height="100px" />
<Text>{props.label}</Text>
<MeasurementUnitInput
defaultUnit={props.defaultUnit || "ft"}
defaultValue={props.defaultValue || 0.0}
onUnitSet={props.onUnitSet}
onValueSet={props.onValueSet}
unitChoices={props.unitChoices}
/>
</View>
);
}
const styles = StyleSheet.create({});

View File

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

View File

@ -0,0 +1,45 @@
import { Length } from "convert";
import { StyleSheet, Text, View } from "react-native";
import { NumberInput, NumberInputProps } from "./NumberInput";
export type t_length_unit = "foot" | "inch"
export type MeasurementInputProps = NumberInputProps & {
units?: Length,
}
export function MeasurementInput({onValueSet, defaultValue: defaultValue, label, units}: MeasurementInputProps) {
units = units || "ft";
return (
<View style={styles.inputWrapper}>
<NumberInput
onValueSet={v => onValueSet && onValueSet(v)}
defaultValue={defaultValue}
label={label}
/>
</View>
)
}
const styles = StyleSheet.create({
inputWrapper: {
alignItems: "flex-start",
flexDirection: "row",
verticalAlign: "middle"
},
unitHints: {
padding: 10,
fontSize: 20,
verticalAlign: "middle",
},
lengthInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 25,
},
})

View File

@ -0,0 +1,51 @@
import { dimensions_t, length_t } from "@/lib/dimensions";
import { Length } from "convert";
import { MeasurementInput, MeasurementInputProps } from "./MeasurementInput";
import UnitChooser, {
UnitChooserPropsBase,
} from "./UnitChooser";
import { StyleSheet, View } from "react-native";
export type MeasurementUnitInputProps = MeasurementInputProps &
UnitChooserPropsBase & {
defaultValue: number;
unitChoices?: Length[];
};
export default function MeasurementUnitInput({
onValueSet,
onUnitSet,
defaultValue,
unitChoices,
defaultUnit,
label,
units,
}: MeasurementUnitInputProps) {
return (
<View style={unitChoices ? styles.inputRow : styles.inputCol}>
<MeasurementInput
onValueSet={onValueSet}
defaultValue={defaultValue}
label={label}
units={units}
/>
{unitChoices && (
<UnitChooser
defaultUnit={defaultUnit}
choices={unitChoices}
onUnitSet={onUnitSet}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
inputRow: {
flexDirection: "row",
},
inputCol: {
}
})

View File

@ -0,0 +1,54 @@
import { StyleSheet, TextInput } from "react-native";
export type NumberInputProps = {
defaultValue: number;
onValueSet: (value: number) => any;
label?: string;
};
export function NumberInput({
defaultValue,
onValueSet,
label,
}: NumberInputProps) {
const defValue = Number.isNaN(defaultValue) ? 0 : defaultValue;
function doOnValueSet(value: string) {
const iVal = parseFloat(value) || parseInt(value);
onValueSet && onValueSet(iVal);
}
const sDefValue = new String(defValue).valueOf();
return (
<TextInput
clearTextOnFocus={true}
defaultValue={sDefValue}
onChangeText={doOnValueSet}
inputMode="decimal"
style={styles.numberInput}
aria-label={label || "Enter measurement"}
/>
);
}
const styles = StyleSheet.create({
inputWrapper: {
alignItems: "flex-start",
flexDirection: "row",
verticalAlign: "middle",
},
unitHints: {
padding: 10,
fontSize: 20,
verticalAlign: "middle",
},
numberInput: {
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 25,
},
});

View File

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

38
components/Price.tsx Normal file
View File

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

View File

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

View File

@ -0,0 +1,204 @@
import { Product, productPriceFor } from "@/lib/product";
import { dimensions_t } from "@/lib/dimensions";
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 ProductList from "./ProductList";
import convert, { Length } from "convert";
import PercentDamage from "./PercentDamange";
import MeasurementUnitInput from "./MeasurementUnitInput";
import UnitChooser from "./UnitChooser";
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(productPriceFor(activeProduct, measurement, percentDamage));
}, 50);
return function () {
clearInterval(iv);
};
},
[activeProduct, measurement, percentDamage]
);
function onMeasurementSet(dimensions: dimensions_t) {
setMeasurement(dimensions);
activeProduct &&
setPrice(productPriceFor(activeProduct, measurement, percentDamage));
}
function onLengthSet(l: number) {
setMeasurement({ ...measurement, l });
onMeasurementSet && onMeasurementSet({ ...measurement, l });
}
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 ? (
<View style={{flex: 1, flexDirection: "row"}}>
<AreaInput
defaultValue={activeProduct.dimensions}
onMeasurementSet={onMeasurementSet}
widthLabel="enter width"
lengthLabel="enter length"
units={measurement.u}
/>
<UnitChooser
choices={["in", "ft"]}
onUnitSet={onUnitChosen}
defaultUnit={activeProduct.dimensions.u}
/>
</View>
) : (
<MeasurementUnitInput
defaultValue={activeProduct.dimensions.l}
onValueSet={onLengthSet}
onUnitSet={onUnitChosen}
defaultUnit={activeProduct.dimensions.u}
unitChoices={["ft", "in"]}
/>
)
) : (
<Text>Please select a product</Text>
)}
</View>
</View>
{activeProduct && (
<View style={styles.damageWrapper}>
<PercentDamage onSetPercentage={onSetPercentDamage} />
</View>
)}
<ProductList onProductSelected={onProductSelected} productType="lumber" />
</SafeAreaView>
);
}
export const styles = StyleSheet.create({
wrapper: {
overflow: "scroll",
},
bigPriceWrapper: {
alignContent: "center",
},
bigPrice: {
alignSelf: "center",
fontSize: 40,
marginTop: 100,
marginBottom: 100,
},
inputWrapper: {
flexDirection: "row",
alignItems: "flex-start",
verticalAlign: "middle",
},
unitSelector: {},
inputAndUnitWrapper: {
flexDirection: "row",
alignSelf: "center",
},
widthInput: {
width: 200,
borderWidth: 1,
borderRadius: 4,
borderColor: "grey",
padding: 4,
margin: 4,
fontSize: 30,
},
activeProduct: {
borderWidth: 2,
borderColor: "black",
borderStyle: "solid",
},
inactiveProduct: {},
titleContainer: {
flexDirection: "row",
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
},
productTileTouchable: {
margin: 10,
padding: 20,
backgroundColor: "grey",
},
productTileTouchableActive: {
borderWidth: 2,
borderStyle: "solid",
borderColor: "black",
margin: 10,
padding: 20,
},
productTileText: {
textAlign: "center",
color: "white",
},
productTileTextActive: {
textAlign: "center",
color: "black",
},
productTileCover: {
padding: 4,
},
damageWrapper: {
paddingVertical: 10,
paddingHorizontal: 10,
},
});

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { Dimensions, ScrollView, StyleSheet } from "react-native";
import { ProductTile } from "./ProductTile";
import { Product, product_type_t } from "@/lib/product";
import { useState } from "react";
import { selectProducts } from "@/features/product/productSlice";
import { useAppSelector } from "@/app/store";
const windowDimensions = Dimensions.get('window');
export type ProductSelectionProps = {
onProductSelected?: (product: Product) => any;
productType?: product_type_t;
};
export default function ProductList({
productType,
onProductSelected,
}: ProductSelectionProps) {
const [activeProduct, setActiveProduct] = useState(null as null | Product);
const products = useAppSelector(selectProducts)
.filter((p) => !!p)
.filter((p: Product) => (!productType) || p.type === productType)
.filter((p) => {
return !!p.dimensions;
});
function doOnProductSelected(product: Product) {
setActiveProduct(product);
onProductSelected && onProductSelected(product);
}
return (
<ScrollView style={styles.productSelectorFlatList} contentContainerStyle={styles.content} aria-label="product list">
{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,
height: windowDimensions.height - 200,
width: windowDimensions.width,
},
content: {
alignItems: "flex-start",
flexWrap: "wrap",
flexDirection: "row",
}
});

View File

@ -1,44 +1,84 @@
import { Product } from "@/lib/product"
import { ImageBackground, StyleProp, StyleSheet, Text, ViewStyle } from "react-native";
import { Product, priceDisplay, pricePerUnitDisplay } from "@/lib/product";
import { LinearGradient } from "expo-linear-gradient";
import {
ImageBackground,
Pressable,
StyleProp,
StyleSheet,
Text,
TouchableHighlight,
View,
ViewStyle,
} from "react-native";
import { AnimatedStyle } from "react-native-reanimated";
import { View } from "react-native-reanimated/lib/typescript/Animated";
export type OnProductSelectedFunc = (product : Product) => any;
export type OnProductSelectedFunc = (product: Product) => any;
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
type StyleSpec = {
highlight?: MyStyle;
text?: MyStyle;
image?: MyStyle;
};
export type ProductTileProps = {
product: (Product),
onProductSelected?: OnProductSelectedFunc,
style?: {
tile?: MyStyle,
image?: MyStyle,
}
}
product: Product;
onProductSelected?: OnProductSelectedFunc;
isActive: boolean;
};
const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, style} : ProductTileProps) {
const src = product.attributes.image || FALLBACK_IMAGE;
return (
<View style={style?.tile}>
<ImageBackground
src={src}
resizeMode="cover"
style={styles.image}
>
<Text style={styles.text}>{product.attributes.name || `Product ${product.id}`}</Text>
<Text style={styles.text}>{ product.pricePerUnit.toString() } / {product.measure.value} {product.measure.unit.symbol} </Text>
</ImageBackground>
</View>
);
export function ProductTile({
product,
onProductSelected,
isActive,
}: ProductTileProps) {
const k = isActive ? "active" : "default";
const BLUE_HILIGHT = "#caceff";
const BLUE = "#8b9cff";
const GRAY_HILIGHT = "#ffffff";
const GRAY = "#b1b1b1";
const activeColors = [BLUE_HILIGHT, BLUE, BLUE, BLUE];
const inactiveColors = [GRAY_HILIGHT, GRAY, GRAY, GRAY];
const priceDisplay = pricePerUnitDisplay(product);
return (
<LinearGradient
colors={isActive ? activeColors : inactiveColors}
style={styles.gradientButton}
>
<Pressable
style={styles.button}
aria-label={`product ${product.id}`}
onPress={() => onProductSelected && onProductSelected(product)}
>
<Text style={styles.text}>
{product.attributes?.name || `Product ${product.id}`} ({priceDisplay})
</Text>
</Pressable>
</LinearGradient>
);
}
const styles = StyleSheet.create({
image: {
},
text: {
},
})
gradientButton: {
borderRadius: 10,
borderWidth: 1,
borderColor: "gray",
borderStyle: "solid",
margin: 1,
width: 300,
marginVertical: 10,
marginHorizontal: 10,
},
button: {
},
text: {
paddingVertical: 30,
paddingHorizontal: 40,
}
});

View File

@ -0,0 +1,95 @@
import { Length } from "convert";
import { useState } from "react";
import {
Button,
Pressable,
StyleSheet,
Text,
TouchableHighlight,
View,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
export type UnitChooserPropsBase = {
onUnitSet?: (l: Length) => any;
activeColor?: string;
defaultColor?: string;
defaultUnit?: Length;
};
export type UnitChooserProps = UnitChooserPropsBase & {
choices: Length[];
};
export default function UnitChooser({
choices,
onUnitSet,
activeColor,
defaultColor,
defaultUnit,
}: UnitChooserProps) {
const [value, setValue] = useState(defaultUnit || (choices[0] as Length));
activeColor = activeColor || "lightblue";
defaultColor = defaultColor || "lightgrey";
function doChoiceClicked(choice: Length) {
setValue(choice);
onUnitSet && onUnitSet(choice);
}
const BLUE_HILIGHT = "#caceff";
const BLUE = "#8b9cff";
const GRAY_HILIGHT = "#ffffff";
const GRAY = "#b1b1b1";
const activeColors = [BLUE_HILIGHT, BLUE, BLUE, BLUE];
const inactiveColors = [GRAY_HILIGHT, GRAY, GRAY, GRAY];
return (
<View style={styles.unitChooser}>
{choices.map((ci) => {
return (
<LinearGradient
colors={ci === value ? activeColors : inactiveColors}
style={styles.gradientButton}
>
<TouchableHighlight style={{padding: 5, }} onPress={() => doChoiceClicked(ci)} key={ci}>
<Text style={{padding: 5, fontSize: 16}}>{ci}</Text>
</TouchableHighlight>
</LinearGradient>
);
})}
</View>
);
}
const styles = StyleSheet.create({
gradientButton: {
borderRadius: 10,
borderWidth: 1,
borderColor: "gray",
borderStyle: "solid",
margin: 1,
},
unitChooser: {
flexDirection: "row",
verticalAlign: "middle",
padding: 4,
},
textActive: {
marginTop: 2,
marginBottom: 2,
marginLeft: 10,
marginRight: 10,
fontSize: 25,
},
textDefault: {
marginTop: 2,
marginBottom: 2,
marginLeft: 10,
marginRight: 10,
fontSize: 25,
},
unitButton: {},
});

View File

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

View File

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react-native';
import { AreaRugTag } from '@/components/AreaRugTag';
import { area_t } from '@/lib/dimensions';
import dayjs from 'dayjs';
import initialProducts from '@/__fixtures__/initialProducts';
import { Product } from '@/lib/product';
describe('AreaRugTag', () => {
it('renders correctly with dimensions, price per area, date and currency symbol', () => {
const dimensions: area_t = { l: 10, w: 20, u: 'ft' };
const date = dayjs();
const currencySymbol = '$';
const product = initialProducts.find(p => "area_rug" === p.type) as Product;
render(
<AreaRugTag
dimensions={dimensions}
product={product}
date={date}
currencySymbol={currencySymbol}
/>
);
expect(screen.getByText(`${dimensions.l} x ${dimensions.w}`)).toBeTruthy();
// expect(screen.getByLabelText('area rug price')).toContain(currencySymbol);
expect(screen.getByText(date.format('YYYY/MM/DD'))).toBeTruthy();
expect(screen.getByLabelText('this week\'s color')).toBeTruthy();
});
});

View File

@ -0,0 +1,64 @@
import React from "react";
import {
render,
fireEvent,
screen,
within,
act,
} from "@testing-library/react-native";
import CarpetRollCalculator from "@/components/CarpetRollCalculator";
import { renderWithProviders } from "@/lib/rendering";
import allProducts from "@/__fixtures__/initialProducts";
import { Product, pricePerUnitDisplay } from "@/lib/product";
import initialProducts from "@/__fixtures__/initialProducts";
jest.useFakeTimers();
const areaRugProducts = allProducts.filter((p) => "area_rug" === p.type);
describe("CarpetRollCalculator", () => {
it("should render correctly", () => {
renderWithProviders(<CarpetRollCalculator />, {
products: initialProducts,
});
const areaRug = initialProducts.find(
(p) => p.type === "area_rug"
) as Product;
const areaRugLabel = `product ${areaRug.id}`;
act(() => {
fireEvent.press(screen.getByLabelText(areaRugLabel));
});
// Test the interaction with the width input
const widthInput = screen.getByLabelText("width");
act(() => {
fireEvent.changeText(widthInput, "10");
});
// Test the interaction with the outer diameter input
const outerDiameterInput = screen.getByLabelText("outer diameter");
act(() => {
fireEvent.changeText(outerDiameterInput, "3");
});
// Test the interaction with the inner diameter input
const innerDiameterInput = screen.getByLabelText("inner diameter");
act(() => {
fireEvent.changeText(innerDiameterInput, "1");
});
// Test the interaction with the number of rings input
const numRingsInput = screen.getByLabelText("number of rings");
act(() => {
fireEvent.changeText(numRingsInput, "5");
});
jest.advanceTimersByTime(3000);
// Test the interaction with the price display
const { getByText } = within(screen.getByLabelText("area rug price"));
expect(getByText(/\$.*58.*\..*19.*/)).toBeTruthy();
});
});

View File

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

View File

@ -1,50 +1,51 @@
import { Product } from "@/lib/product"
import {ProductAttributeEditor} from "../ProductAttributeEditor"
import { area } from "enheter"
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from "react";
import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
import { LumberProduct, Product } from "@/lib/product";
import { ProductAttributeEditor } from "../ProductAttributeEditor";
import { fireEvent, render, screen } from "@testing-library/react-native";
import { renderWithProviders } from "@/lib/rendering";
describe("Product editor tests", () => {
it("Product attributes can be deleted", async () => {
const product = new Product(
100,
area("squareFoot", 4 * 7)
);
const onChange = jest.fn();
const onDelete = jest.fn();
render(
<ProductAttributeEditor
attributeKey="name"
attributeValue="product"
product={product}
onChange={onChange}
onDelete={onDelete}
/>);
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
fireEvent.press(await screen.getByLabelText("Delete Attribute"));
expect(onDelete).toHaveBeenCalled();
});
it("Product attributes can be modified", async () => {
const productName = "Fun Product";
const product = new Product(
100,
area("squareFoot", 4 * 7),
{ name: productName },
);
const onChange = jest.fn();
const onDelete = jest.fn();
render(
<ProductAttributeEditor
attributeKey="Name"
attributeValue="product"
product={product}
onChange={onChange}
onDelete={onDelete}
/>);
fireEvent.press(screen.getByText("product")); // Use getByText instead of findByText
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
expect(onChange).toHaveBeenCalled();
})
})
const productName = "Fun Product";
it("Product attributes can be deleted", async () => {
const onChange = jest.fn();
const onDelete = jest.fn();
renderWithProviders(
<ProductAttributeEditor
attributeKey="name"
attributeValue="product"
onChangeAttribute={onChange}
onDelete={onDelete}
/>
);
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
fireEvent.press(await screen.getByLabelText("Delete Attribute"));
expect(onDelete).toHaveBeenCalled();
});
it("Product attributes can be modified", async () => {
const product: Product = {
pricePerUnit: 10,
dimensions: {
l: 40,
u: "ft",
},
type: "lumber",
};
const onChange = jest.fn();
const onDelete = jest.fn();
const onKeyChange = jest.fn();
render(
<ProductAttributeEditor
attributeKey="old test key"
attributeValue="old test value"
onChangeAttribute={onChange}
onDelete={onDelete}
onChangeAttributeKey={onKeyChange}
/>
);
fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
expect(onKeyChange).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
expect(onChange).toHaveBeenCalled();
fireEvent.press(screen.getByLabelText("Delete Attribute"));
expect(onDelete).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,69 @@
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, pricePerUnitDisplay, productPriceFor } from '@/lib/product';
import initialProducts from '@/__fixtures__/initialProducts';
jest.useFakeTimers();
const mockAreaProduct = initialProducts.find(p => 'w' in p.dimensions ) as Product
const mockLengthProduct = initialProducts.find(p => (!('w' in p.dimensions)) ) as Product
describe('ProductCalculatorSelector', () => {
it('renders correctly', () => {
renderWithProviders(
(<ProductCalculatorSelector />),
{
products: [
mockAreaProduct,
mockLengthProduct,
],
}
)
expect(screen.getByText('Please select a product')).toBeTruthy();
const label = `${mockAreaProduct.attributes?.name} (${pricePerUnitDisplay(mockAreaProduct)})`;
expect(screen.getByText(label)).toBeTruthy();
});
it('a product can be selected', () => {
renderWithProviders(
(<ProductCalculatorSelector />),
{
products: [
mockLengthProduct,
mockAreaProduct,
]
}
);
expect(screen.getByText('Please select a product')).toBeTruthy();
const areaLabel = `${mockAreaProduct.attributes?.name} (${pricePerUnitDisplay(mockAreaProduct)})`;
act(()=>{
fireEvent.press(screen.getByText(areaLabel));
})
const lengthInput = screen.getByLabelText("enter length");
const widthInput = screen.getByLabelText("enter length");
expect(lengthInput).toBeTruthy();
expect(widthInput).toBeTruthy();
act(() => {
fireEvent.press(screen.getByText("in"));
})
act(() => {
fireEvent.changeText(lengthInput, "2");
fireEvent.changeText(widthInput, "4");
});
jest.advanceTimersByTime(3000);
const price = productPriceFor(mockAreaProduct, {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(/\$.*15.*\.00/)).toBeTruthy();
});
});

View File

@ -1,25 +1,55 @@
import { renderWithProviders } from "@/lib/rendering";
import { ProductEditor } from "@/components/ProductEditor";
import {products as fixtures} from "@/__fixtures__/initialProducts";
import { screen } from "@testing-library/react-native";
import { act, fireEvent, screen } from "@testing-library/react-native";
import { selectProducts } from "@/features/product/productSlice";
import { LumberProduct, Product, productLabel } from "@/lib/product";
import initialProducts from "@/__fixtures__/initialProducts";
describe("ProductEditor", () => {
it("renders correctly", async () => {
const {store} = renderWithProviders(<ProductEditor />, {
products: fixtures,
});
const state1 = store.getState();
const products = selectProducts(state1);
expect(products).toHaveLength(6);
// Check if the product names are rendered
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[1].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[2].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[3].attributes.name as string)).toBeTruthy();
const productName = "Flooring";
const mockProduct = initialProducts[0];
it("renders correctly", async () => {
const { store } = renderWithProviders(<ProductEditor />, {
products: [mockProduct],
});
const state1 = store.getState();
let products = selectProducts(state1);
expect(products).toHaveLength(1);
// Check if the product names are rendered
expect(
screen.getByText(mockProduct.attributes?.name as string)
).toBeTruthy();
const label = productLabel(mockProduct);
// Start to edit a product
act(() => {
fireEvent.press(screen.getByText(label));
})
// 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);
act(() => {
fireEvent.press(screen.getByLabelText("delete product"));
})
products = selectProducts(store.getState());
expect(products.length).toBe(0);
});
});

View File

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

View File

@ -0,0 +1,38 @@
import React from 'react';
import { renderWithProviders } from '@/lib/rendering';
import { Product, pricePerUnitDisplay } from '@/lib/product';
import ProductList from '@/components/ProductList';
import initialProducts from '@/__fixtures__/initialProducts';
import { screen } from '@testing-library/react-native';
describe('ProductList', () => {
it('renders without crashing', () => {
const { getByTestId } = renderWithProviders(<ProductList />, {
products: initialProducts,
});
expect(screen.getByLabelText('product list')).toBeTruthy();
});
it('renders products correctly', () => {
const mockProduct = initialProducts[0] as Product;
const label = `${mockProduct.attributes?.name} (${pricePerUnitDisplay(mockProduct)})`;
const { getByText } = renderWithProviders(<ProductList />, {
products: [mockProduct],
});
expect(getByText(label)).toBeTruthy();
});
it('renders only area_rug products', () => {
const areaRug = initialProducts.find(p => p.type == "area_rug") as Product;
const label = `${areaRug?.attributes?.name} (${pricePerUnitDisplay(areaRug)})`;
renderWithProviders(<ProductList productType='area_rug' />, {
products: initialProducts,
});
expect(screen.getByText(label)).toBeTruthy();
});
});

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

25
eas.json Normal file
View File

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

View File

@ -1,59 +1,274 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Id, Product } from '@/lib/product';
import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { attributesAsList, Id, Product } from "@/lib/product";
import { dimensions_t, length_t } from "@/lib/dimensions";
import uuid from "react-native-uuid";
import { RootState } from '@/app/store';
import { AppStore, RootState } from "@/app/store";
import { Length } from "convert";
const initialState = {
products: [] as Product [],
}
export const DEFAULT_UNITS: Length = "ft";
export type PlywoodCalculationState = {
product?: Id | null;
units: Length;
};
export type CarpetCalculationState = {
product?: Id | null;
length: length_t;
inner_d: length_t;
outer_d: length_t;
};
export type AppStoreState = {
products: Product[];
calculations?: {
plywood: PlywoodCalculationState;
carpet: CarpetCalculationState;
};
};
export const DEFAULT_PRELOADED_STATE = {
products: [] as Product[],
calculations: {
plywood: {
product: undefined,
units: DEFAULT_UNITS,
},
carpet: {
product: undefined,
length: {
l: 0,
u: DEFAULT_UNITS,
},
inner_d: {
l: 0,
u: DEFAULT_UNITS,
},
outer_d: {
l: 0,
u: DEFAULT_UNITS,
},
},
},
} as AppStoreState;
const initialState: AppStoreState = DEFAULT_PRELOADED_STATE;
export type UpdateAttribute = {
product_id: Id;
attributeKey: string;
attributeValue: any;
};
export type UpdateAttributeKey = {
product_id: Id;
oldKey: string;
newKey: string;
};
export type AddAttribute = {
product_id: Id;
};
const cp = (obj: any) => JSON.parse(JSON.stringify(obj));
const productsState = createSlice({
name: 'products-slice',
initialState,
reducers: {
createProduct(state, action: PayloadAction<Product>) {
if (!state) {
return initialState
}
const product = action.payload;
if (!product.id) product.id = uuid.v4().toString();
state.products = [...state.products, action.payload];
return state;
},
updateProduct(state, action: PayloadAction<Product>) {
if (!state) return initialState;
const product = action.payload;
if (!product.id) {
throw new Error("Product has no ID");
}
state.products = state.products.map((prod) => {
return prod.id === product.id ? product : prod;
})
return state;
},
deleteProduct(state, action: PayloadAction<Id>) {
if (!state) return initialState;
state.products = state.products.filter((prod) => {
prod.id !== action.payload;
})
return state;
}
}
name: "products-slice",
initialState,
reducers: {
setPlywoodCalculation(
state,
action: PayloadAction<PlywoodCalculationState>
) {
if (!state) {
return initialState;
}
const newCalc = action.payload;
state.calculations.plywood = newCalc;
return state;
},
setCarpetCalculation(state, action: PayloadAction<CarpetCalculationState>) {
if (!state) {
return initialState;
}
const newCalc = action.payload;
state.calculations.carpet = newCalc;
return state;
},
createProduct(state, action: PayloadAction<Product>) {
if (!state) {
return initialState;
}
const product = action.payload;
if (!product.id) product.id = uuid.v4().toString();
state.products = [...state.products, action.payload];
return state;
},
deleteProduct(state, action: PayloadAction<Id>) {
if (!state) return initialState;
return {
...state,
products: [
...state.products.filter((prod) => {
return prod.id?.valueOf() !== action.payload.valueOf();
}),
],
};
},
updateAttribute(state, action: PayloadAction<UpdateAttribute>) {
const { product_id, attributeKey, attributeValue } = action.payload;
if (!state) return initialState;
return {
...state,
products: state.products.map((prod) => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[attributeKey] = attributeValue;
return {
...prod,
attributes,
};
}),
};
},
changeKey(state, action: PayloadAction<UpdateAttributeKey>) {
if (!state) return initialState;
const { product_id, oldKey, newKey } = action.payload;
return {
...state,
products: state.products.map((prod) => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[newKey] = attributes[oldKey];
delete attributes[oldKey];
attributes.id = prod.id;
return {
...prod,
attributes,
};
}),
};
},
addAttribute(state, action: PayloadAction<Id>) {
if (!state) return initialState;
const product_id = action.payload;
state.products = state.products.map((prod) => {
if (prod.id !== product_id) return prod;
const i = (
Object.keys(prod.attributes || {}).filter((k) =>
k.match(/attribute [\d]+/)
) || []
).length;
const newAttribute = `attribute ${i + 1}`;
return {
...prod,
attributes: {
...prod.attributes,
[newAttribute]: `value`,
},
};
});
return state;
},
deleteAttribute(
state,
action: PayloadAction<{ product_id: Id; attribute: string }>
) {
if (!state) return initialState;
const { product_id, attribute } = action.payload;
return {
...state,
products: state.products.map((prod) => {
if (prod.id !== product_id) return prod;
const attributes = Object.fromEntries(
Object.entries(prod).filter(([k, v]) => k !== attribute)
);
return {
...prod,
attributes,
};
}),
};
},
updatePrice(
state,
action: PayloadAction<{ product_id: Id; pricePerUnit: number }>
) {
if (!state) return initialState;
const { product_id, pricePerUnit } = action.payload;
state.products = state.products.map((prod) => {
if (prod.id !== product_id) return prod;
prod.pricePerUnit = pricePerUnit;
return prod;
});
return state;
},
updateDimensions(
state,
action: PayloadAction<{ product_id: Id; dimensions: dimensions_t }>
) {
if (!state) return initialState;
const { product_id, dimensions } = action.payload;
console.log("Changing dimensions: %o", action.payload);
return {
...state,
products: state.products.map((prod) => {
if (prod.id !== product_id) return prod;
return {
...prod,
dimensions,
};
}),
};
},
},
});
export const selectProducts = (state : RootState) => {
return state.products;
}
export const selectProducts = (state: RootState) => {
return state.products;
};
export const selectPlywoodCalc = (state: RootState) => {
return state.calculations.plywood;
};
export const selectCarpetCalc = (state: RootState) => {
return state.calculations.carpet;
};
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.attributes ? attributesAsList(p.attributes) : []];
})
);
}
);
export const actions = {
...productsState.actions
...productsState.actions,
};
export const {
createProduct,
updateProduct,
deleteProduct,
setCarpetCalculation,
setPlywoodCalculation,
createProduct,
deleteProduct,
changeKey,
updateAttribute,
addAttribute,
deleteAttribute,
updatePrice,
updateDimensions,
} = productsState.actions;
export default productsState.reducer;

View File

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

View File

@ -1,23 +1,47 @@
import { length } from "enheter";
import { Product } from "../product";
import { Id, Product, productPriceFor } from "../product";
import uuid from "react-native-uuid"
describe("Product tests", () => {
it(`Length product gives correct price for a shorter length`, () => {
const standard = new Product(20, { l: 4, u: "feet" });
const comparison = standard.priceFor({ l: 2, u: "feet" });
expect(comparison).toEqual(10);
const standard : Product = {
id: uuid.v4().valueOf() as Id,
dimensions: {
l: 5,
u: "ft",
},
type: "lumber",
pricePerUnit: 10,
};
const comparison = productPriceFor(standard, { l: 2, u: "feet" });
expect(comparison).toEqual(4);
});
it(`Length product gives correct price for a longer length`, () => {
const standard = new Product(20, {l: 4, u : "feet"});
const comparison = standard.priceFor({l : 8, u : "feet"});
expect(comparison).toEqual(40);
const standard : Product = {
id: uuid.v4().valueOf() as Id,
dimensions: {
l: 5,
u: "ft",
},
type: "lumber",
pricePerUnit: 10,
};
const comparison = productPriceFor(standard, { l: 10, u: "feet" });
expect(comparison).toEqual(20);
});
it(`Length product gives correct price if different units`, () => {
const standard = new Product(10, {l: 1, u : "feet"});
const comparison = standard.priceFor({l : 24, u: "inch"});
expect(comparison).toBeCloseTo(20, 4);
const standard : Product = {
id: uuid.v4().valueOf() as Id,
dimensions: {
l: 12,
u: "in",
},
type: "lumber",
pricePerUnit: 1,
};
const comparison = productPriceFor(standard, { l: 2, u: "feet" });
expect(comparison).toBeCloseTo(2, 1);
});
});

59
lib/dimensions.ts Normal file
View File

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

View File

@ -1,99 +1,103 @@
import uuid from "react-native-uuid";
import convert, { Area, Length } from "convert";
import { dimensions_t, area_t, dimensionsDisplay } from "./dimensions";
import { matchDimensions } from "./dimensions";
import { Area, Length, Unit } from "convert";
export type Id = string;
export type Currency = "USD";
export type ProductAttributes = {
id?: string,
name?: string,
image?: string,
description?: string,
depth?: string,
currency?: Currency,
// [index:string]: any,
}
export type ProductData = {
id?: Id,
pricePerUnit: number,
measurement: {
unit: string,
value: number,
dimension: number,
},
attributes?: ProductAttributes,
id?: string;
name?: string;
image?: string;
description?: string;
depth?: string;
currency?: Currency;
[index: string]: any;
};
export type length_t = {
l: number, u: Length
export function dimensionArea(d: dimensions_t) {
return "w" in d ? d.w * d.l : 0;
}
export type area_t = length_t & {
w: number,
export const PRODUCT_TYPES = ["lumber", "area_rug"] as const;
export type product_type_t = (typeof PRODUCT_TYPES)[number];
export type Product = {
id?: Id;
pricePerUnit: number;
dimensions: dimensions_t;
type: product_type_t;
attributes?: ProductAttributes;
};
export type LumberProduct = Product & {
type: "lumber";
};
export type AreaRugProduct = Product & {
type: "lumber";
};
export function productPriceFor(
product: Product,
dimensions: dimensions_t,
damage: number = 0
): number {
if (Number.isNaN(damage)) damage = 0;
const dim = matchDimensions(dimensions, product.dimensions);
return (
(dim.w
? (dimensionArea(dim) / dimensionArea(product.dimensions)) *
product.pricePerUnit
: (dim.l / product.dimensions.l) * product.pricePerUnit) *
(1.0 - damage)
);
}
export type dimensions_t = area_t | length_t;
export function priceDisplay(price: number) {
return price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export type product_type_t = "area" | "length";
export function pricePerUnitDisplay(product: Product) {
const p = priceDisplay(product.pricePerUnit);
const { l, u } = product.dimensions;
const w = (product.dimensions as area_t).w || null;
const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
return `$${p} per ${d}`;
}
export const isArea = (d: dimensions_t) => ("width" in d);
export const isLength = (d: dimensions_t) => (!("width" in d));
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"
export function attributesAsList(attributes: ProductAttributes) {
return Object.entries(attributes).map(([key, value]) => {
return { key, value };
});
}
export class Product {
public id: string;
public area?: area_t;
public length?: length_t;
public presentUnits: Length;
export function productLabel(
product: Product,
{ pricing }: { pricing: boolean } = { pricing: false }
) {
const n = product.attributes?.name || `Product ${product.id}`;
const p = priceDisplay(product.pricePerUnit);
const d = dimensionsDisplay(product.dimensions);
if (!pricing) {
return n;
}
return `${n} ($${p} per ${d})`;
}
constructor(public pricePerUnit: number, dimensions: dimensions_t, public attributes: ProductAttributes = {},) {
this.id = attributes.id || uuid.v4().toString();
this.presentUnits = dimensions.u;
if ("w" in dimensions) {
this.area = {
l: convert(dimensions.l, dimensions.u).to("meter"),
w: convert(dimensions.w, dimensions.u).to("meter"),
u: "meter"
}
} else {
this.length = {
l: convert(dimensions.l, dimensions.u).to("meter"),
u: "meter"
};
}
}
public priceFor(dimensions: dimensions_t): number {
if (this.area && "w" in dimensions) {
const thisA = this.area.l * this.area.w;
const otherA = convert(
dimensions.w,
dimensions.u
).to("meter") * convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherA / thisA) * this.pricePerUnit;
} if (this.length) {
const thisL = this.length.l;
const otherL = convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherL / thisL) * this.pricePerUnit;
}
throw new Error(`Invalid dimensions: ${dimensions}`);
}
get attributesAsList() {
return Object.entries(this.attributes).map(([key, value]) => {
return { key, value }
})
}
public removeAttribute(key: string) {
delete this.attributes[key];
}
}
export function removeAttribute(
attributes: { [key: string]: any },
key: string
) {
return Object.fromEntries(
Object.entries(attributes).filter(([k, v]) => {
k == key;
})
);
}

View File

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

15
lib/util.ts Normal file
View File

@ -0,0 +1,15 @@
function waitForWindow(): Promise<Window> {
return new Promise((resolve) => {
if (typeof window !== 'undefined') {
resolve(window);
} else {
const intervalId = setInterval(() => {
if (typeof window !== 'undefined') {
clearInterval(intervalId);
resolve(window);
}
}, 100);
}
});
}

18022
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,65 +5,68 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web --offline",
"test": "jest --watchAll",
"lint": "expo lint"
"lint": "expo lint",
"apk:android": "eas build --platform android --local"
},
"dependencies": {
"@babel/runtime": "^7.24.7",
"@babel/runtime": "^7.25.0",
"@expo/config-plugins": "~8.0.8",
"@expo/prebuild-config": "~7.0.8",
"@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native/assets-registry": "^0.74.84",
"@react-navigation/native": "^6.1.17",
"@reduxjs/toolkit": "^2.2.5",
"@testing-library/react-native": "^12.5.1",
"@types/js-quantities": "^1.6.6",
"@react-native-async-storage/async-storage": "^1.24.0",
"@react-native-community/slider": "^4.5.2",
"@react-native/assets-registry": "^0.74.87",
"@react-navigation/native": "^6.1.18",
"@reduxjs/toolkit": "^2.2.7",
"class-transformer": "^0.5.1",
"convert": "^5.3.0",
"enheter": "^1.0.27",
"esm": "link:@types/js-quantities/esm",
"expo": "~51.0.16",
"dayjs": "^1.11.12",
"expo": "~51.0.28",
"expo-asset": "^10.0.10",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.7",
"expo-doctor": "^1.9.0",
"expo-font": "~12.0.9",
"expo-linear-gradient": "^13.0.2",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.17",
"expo-router": "~3.5.23",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.6",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.3",
"interopRequireDefault": "link:@babel/runtime/helpers/interopRequireDefault",
"js-quantities": "^1.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.2",
"react-native-gesture-handler": "~2.16.2",
"react-native": "0.74.3",
"react-native-element-dropdown": "^2.12.1",
"react-native-gesture-handler": "~2.18.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"react-native-select-dropdown": "^4.0.1",
"react-native-svg": "^15.5.0",
"react-native-uuid": "^2.0.2",
"react-native-web": "~0.19.12",
"react-redux": "^9.1.2",
"redux-remember": "^5.1.0",
"safe-units": "^2.0.1",
"uuid": "^10.0.0"
"redux-persist": "^6.0.0",
"redux-remember": "^5.1.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/core": "^7.25.2",
"@babel/preset-typescript": "^7.24.7",
"@jest/globals": "^29.7.0",
"@testing-library/react-native": "^12.5.3",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.2.79",
"@types/react-test-renderer": "^18.3.0",
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"enzyme-adapter-react-15": "^1.4.4",
"eslint-config-airbnb": "^19.0.4",
"babel-preset-expo": "^11.0.14",
"jest": "^29.7.0",
"jest-expo": "~51.0.3",
"react-native-svg-transformer": "^1.5.0",
"react-test-renderer": "18.2.0",
"ts-jest": "^29.1.5",
"typescript": "~5.3.3"
},
"private": true
}
}

7175
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

54
svg/icon-carpet-roll.svg Normal file
View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="142.37779mm"
height="154.51445mm"
viewBox="0 0 142.37779 154.51445"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="icon-carpet-roll.svg"
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
inkscape:export-xdpi="11.417511"
inkscape:export-ydpi="11.417511"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#585858"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.79909512"
inkscape:cx="427.98409"
inkscape:cy="198.34935"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="681"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-16.165914,9.5264309)">
<path
id="path1159"
style="color:#000000;fill:#000000;fill-rule:evenodd;stroke:#ffffff;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
sodipodi:type="inkscape:offset"
inkscape:radius="0.33110365"
inkscape:original="M 85.214844 -6.9941406 C 70.013101 -6.494485 55.139955 -0.43163169 43.707031 9.5605469 C 30.013867 21.362005 20.944785 38.446198 19.085938 56.449219 C 17.358707 72.332698 21.014781 88.716233 29.230469 102.41016 C 39.520614 119.81061 56.638281 132.98252 76.03125 138.63672 C 82.65448 140.58173 89.540529 141.77025 96.458984 141.68555 C 115.61209 141.94239 134.76485 142.20153 153.91797 142.45703 C 154.55818 134.40231 155.19894 126.34763 155.83984 118.29297 C 135.18432 118.00696 114.52684 117.78015 93.873047 117.42578 C 80.647337 116.41488 67.916649 110.28207 58.582031 100.88867 C 52.070874 94.381035 47.193434 86.30892 44.728516 77.402344 C 42.589935 69.786839 42.32538 61.636547 44.011719 53.955078 C 46.999506 40.237159 56.336432 28.015139 68.902344 21.708984 C 76.179541 18.017237 84.56116 16.49232 92.609375 17.550781 C 105.47803 19.123751 117.29397 27.652918 122.69922 39.457031 C 124.2433 42.820885 125.26799 46.602101 125.57422 50.355469 C 126.08314 55.844903 125.04871 61.62513 122.51172 66.595703 C 118.69587 74.236827 111.48288 80.366569 102.99414 81.986328 C 98.380931 82.881417 93.437488 82.363391 89.1875 80.232422 C 83.668056 77.562868 79.16371 72.357272 78.208984 66.197266 C 77.642576 62.560594 78.60807 58.687652 80.996094 55.859375 C 80.898514 58.890704 81.095836 62.010499 82.367188 64.810547 C 84.05204 68.882567 87.592589 72.214241 91.904297 73.302734 C 95.777353 74.339207 99.867688 73.625621 103.61328 72.421875 C 106.30071 71.528147 108.63458 69.786802 110.41992 67.605469 C 114.58333 62.715455 116.25928 55.924687 115.11328 49.630859 C 113.74274 41.428115 107.89047 34.34961 100.46484 30.791016 C 92.86163 27.075438 83.717791 27.076984 75.933594 30.257812 C 64.775062 34.759192 56.518994 45.396663 54.443359 57.191406 C 52.491012 67.446627 54.957367 78.397692 60.941406 86.929688 C 68.583415 98.108042 81.45297 105.49998 94.955078 106.49414 C 106.54011 107.47506 118.38117 103.97767 127.69727 97.052734 C 140.03453 88.048478 148.27185 73.639108 149.67578 58.419922 C 151.00619 45.171814 147.4177 31.531028 139.85938 20.582031 C 130.27054 6.4471034 114.71504 -3.4768114 97.845703 -6.1816406 C 93.675003 -6.866132 89.439024 -7.1235453 85.214844 -6.9941406 z "
d="M 85.203125,-7.3242188 C 69.920576,-6.8219072 54.978371,-0.73159347 43.488281,9.3105469 29.730781,21.167938 20.62376,38.325311 18.755859,56.416016 c -1.734796,15.95712 1.938409,32.407948 10.191407,46.164064 10.334605,17.47423 27.516469,30.69674 46.990234,36.375 6.645281,1.95148 13.56116,3.1467 20.517578,3.0625 19.153082,0.25684 38.305822,0.51598 57.458982,0.77148 a 0.33113676,0.33113676 0 0 0 0.33399,-0.30664 c 0.64021,-8.0547 1.28097,-16.1094 1.92187,-24.16406 a 0.33113676,0.33113676 0 0 0 -0.32617,-0.35742 c -20.65485,-0.286 -41.30981,-0.51288 -61.960938,-0.86719 h -0.0039 c -13.133341,-1.00893 -25.787797,-7.10439 -35.0625,-16.4375 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 52.343603,94.184995 47.495562,86.16238 45.046875,77.314453 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 42.922997,69.749353 42.661672,61.651864 44.335938,54.025391 47.301902,40.407665 56.579386,28.264581 69.050781,22.005859 a 0.33113676,0.33113676 0 0 0 0.002,-0.002 c 7.218526,-3.661983 15.535683,-5.174225 23.513672,-4.125 a 0.33113676,0.33113676 0 0 0 0.002,0 c 12.749801,1.558443 24.475741,10.023864 29.830081,21.716797 1.528,3.328833 2.54287,7.075443 2.8457,10.787109 a 0.33113676,0.33113676 0 0 0 0,0.0039 c 0.50314,5.427095 -0.52104,11.148138 -3.02734,16.058593 a 0.33113676,0.33113676 0 0 0 -0.002,0.002 c -3.77296,7.555238 -10.91161,13.615485 -19.2832,15.21289 a 0.33113676,0.33113676 0 0 0 0,0.002 c -4.546338,0.882115 -9.418457,0.367934 -13.595702,-1.726562 a 0.33113676,0.33113676 0 0 0 -0.0039,-0.002 c -5.431125,-2.626837 -9.860013,-7.754966 -10.794922,-13.78711 -0.497265,-3.192735 0.282359,-6.536996 2.125,-9.185546 -0.0139,2.705038 0.257235,5.460018 1.404297,7.986328 1.724833,4.159718 5.336057,7.559506 9.757813,8.675781 3.964246,1.059122 8.116269,0.326271 11.890621,-0.886719 a 0.33113676,0.33113676 0 0 0 0.002,0 c 2.75294,-0.915515 5.13542,-2.694581 6.95508,-4.916016 l 0.004,-0.0059 c 4.22887,-4.970422 5.92559,-11.850563 4.76367,-18.238281 v -0.0039 c -1.39169,-8.320828 -7.31354,-15.475053 -14.83007,-19.078125 l -0.002,-0.002 C 92.910555,26.731743 83.676017,26.736335 75.808594,29.951172 64.535605,34.499437 56.212806,45.226465 54.117188,57.134766 c -1.968025,10.346436 0.517884,21.379932 6.552734,29.984375 7.700829,11.262849 20.653527,18.702169 34.257812,19.705079 11.668956,0.98802 23.584106,-2.53175 32.964846,-9.503908 l 0.002,-0.002 c 12.41469,-9.061773 20.69784,-23.548746 22.11133,-38.865234 v -0.002 c 1.33798,-13.328328 -2.27053,-27.040971 -9.87305,-38.054688 v -0.002 C 130.49093,6.1822132 114.86084,-3.7880615 97.898438,-6.5078125 93.705473,-7.195958 89.449462,-7.4542424 85.205078,-7.3242188 a 0.33113676,0.33113676 0 0 0 -0.002,0 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB