ferns-ui 0.36.3 → 0.36.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/ActionSheet.tsx +1231 -0
- package/src/Avatar.tsx +317 -0
- package/src/Badge.tsx +65 -0
- package/src/Banner.tsx +124 -0
- package/src/BlurBox.native.tsx +40 -0
- package/src/BlurBox.tsx +31 -0
- package/src/Body.tsx +32 -0
- package/src/Box.tsx +308 -0
- package/src/Button.tsx +219 -0
- package/src/Card.tsx +23 -0
- package/src/CheckBox.tsx +118 -0
- package/src/Common.ts +2743 -0
- package/src/Constants.ts +53 -0
- package/src/CustomSelect.tsx +85 -0
- package/src/DateTimeActionSheet.tsx +409 -0
- package/src/DateTimeField.android.tsx +101 -0
- package/src/DateTimeField.ios.tsx +83 -0
- package/src/DateTimeField.tsx +69 -0
- package/src/DecimalRangeActionSheet.tsx +113 -0
- package/src/ErrorBoundary.tsx +37 -0
- package/src/ErrorPage.tsx +44 -0
- package/src/FernsProvider.tsx +21 -0
- package/src/Field.tsx +299 -0
- package/src/FieldWithLabels.tsx +36 -0
- package/src/FlatList.tsx +2 -0
- package/src/Form.tsx +182 -0
- package/src/HeaderButtons.tsx +107 -0
- package/src/Heading.tsx +53 -0
- package/src/HeightActionSheet.tsx +104 -0
- package/src/Hyperlink.tsx +181 -0
- package/src/Icon.tsx +24 -0
- package/src/IconButton.tsx +165 -0
- package/src/Image.tsx +50 -0
- package/src/ImageBackground.tsx +14 -0
- package/src/InfoTooltipButton.tsx +23 -0
- package/src/Layer.tsx +17 -0
- package/src/Link.tsx +17 -0
- package/src/Mask.tsx +21 -0
- package/src/MediaQuery.ts +46 -0
- package/src/Meta.tsx +9 -0
- package/src/Modal.tsx +248 -0
- package/src/ModalSheet.tsx +58 -0
- package/src/NumberPickerActionSheet.tsx +66 -0
- package/src/Page.tsx +133 -0
- package/src/Permissions.ts +44 -0
- package/src/PickerSelect.tsx +553 -0
- package/src/Pill.tsx +24 -0
- package/src/Pog.tsx +87 -0
- package/src/ProgressBar.tsx +55 -0
- package/src/ScrollView.tsx +2 -0
- package/src/SegmentedControl.tsx +102 -0
- package/src/SelectList.tsx +89 -0
- package/src/SideDrawer.tsx +62 -0
- package/src/Spinner.tsx +20 -0
- package/src/SplitPage.native.tsx +160 -0
- package/src/SplitPage.tsx +302 -0
- package/src/Switch.tsx +19 -0
- package/src/Table.tsx +87 -0
- package/src/TableHeader.tsx +36 -0
- package/src/TableHeaderCell.tsx +76 -0
- package/src/TableRow.tsx +87 -0
- package/src/TapToEdit.tsx +221 -0
- package/src/Text.tsx +131 -0
- package/src/TextArea.tsx +16 -0
- package/src/TextField.tsx +401 -0
- package/src/TextFieldNumberActionSheet.tsx +61 -0
- package/src/Toast.tsx +106 -0
- package/src/Tooltip.tsx +269 -0
- package/src/UnifiedScreens.ts +24 -0
- package/src/Unifier.ts +371 -0
- package/src/Utilities.tsx +159 -0
- package/src/WithLabel.tsx +57 -0
- package/src/dayjsExtended.ts +10 -0
- package/src/index.tsx +1346 -0
- package/src/polyfill.d.ts +11 -0
- package/src/tableContext.tsx +80 -0
package/src/Tooltip.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dimensions,
|
|
4
|
+
LayoutChangeEvent,
|
|
5
|
+
LayoutRectangle,
|
|
6
|
+
Platform,
|
|
7
|
+
Pressable,
|
|
8
|
+
View,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import {Portal} from "react-native-portalize";
|
|
11
|
+
|
|
12
|
+
import {TooltipDirection} from "./Common";
|
|
13
|
+
import {Text} from "./Text";
|
|
14
|
+
import {Unifier} from "./Unifier";
|
|
15
|
+
|
|
16
|
+
const TOOLTIP_OFFSET = 8;
|
|
17
|
+
// How many pixels to leave between the tooltip and the edge of the screen
|
|
18
|
+
const TOOLTIP_OVERFLOW_PADDING = 20;
|
|
19
|
+
|
|
20
|
+
type ChildrenMeasurement = {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
pageX: number;
|
|
24
|
+
pageY: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Measurement = {
|
|
28
|
+
children: ChildrenMeasurement;
|
|
29
|
+
tooltip: LayoutRectangle;
|
|
30
|
+
measured: boolean;
|
|
31
|
+
idealDirection?: TooltipDirection;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const overflowLeft = (x: number): boolean => {
|
|
35
|
+
return x < TOOLTIP_OVERFLOW_PADDING;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const overflowRight = (x: number): boolean => {
|
|
39
|
+
const {width: layoutWidth} = Dimensions.get("window");
|
|
40
|
+
return x + TOOLTIP_OVERFLOW_PADDING > layoutWidth;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getTooltipPosition = ({
|
|
44
|
+
children,
|
|
45
|
+
tooltip,
|
|
46
|
+
measured,
|
|
47
|
+
idealDirection,
|
|
48
|
+
}: Measurement): {} | {left: number; top: number} => {
|
|
49
|
+
if (!measured) {
|
|
50
|
+
console.debug("No measurements for child yet, cannot show tooltip yet.");
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
pageY: childrenY,
|
|
56
|
+
height: childrenHeight,
|
|
57
|
+
pageX: childrenX,
|
|
58
|
+
width: childrenWidth,
|
|
59
|
+
}: ChildrenMeasurement = children;
|
|
60
|
+
const {width: tooltipWidth, height: tooltipHeight} = tooltip;
|
|
61
|
+
const horizontalCenter = childrenX + childrenWidth / 2;
|
|
62
|
+
const right = childrenX + childrenWidth + TOOLTIP_OFFSET;
|
|
63
|
+
const left = childrenX - tooltipWidth - TOOLTIP_OFFSET;
|
|
64
|
+
|
|
65
|
+
const top = childrenY - tooltipHeight - TOOLTIP_OFFSET;
|
|
66
|
+
const bottom = childrenY + childrenHeight + TOOLTIP_OFFSET;
|
|
67
|
+
const verticalCenter = top + childrenHeight + TOOLTIP_OFFSET;
|
|
68
|
+
|
|
69
|
+
// Top is overflowed if it would go off either side or the top of the screen.
|
|
70
|
+
const overflowTop = top < TOOLTIP_OVERFLOW_PADDING;
|
|
71
|
+
|
|
72
|
+
// Bottom is overflowed if it would go off either side or the bottom of the screen.
|
|
73
|
+
const overflowBottom =
|
|
74
|
+
bottom + tooltipHeight + TOOLTIP_OVERFLOW_PADDING > Dimensions.get("window").height;
|
|
75
|
+
|
|
76
|
+
// If it would overflow to the right, try to put it above, if not, put it on the left.
|
|
77
|
+
// If it would overflow to the left, try to put it above, if not, put it to the right.
|
|
78
|
+
|
|
79
|
+
// Happy path:
|
|
80
|
+
if (idealDirection === "left" && !overflowLeft(left)) {
|
|
81
|
+
return {left, top: verticalCenter};
|
|
82
|
+
} else if (idealDirection === "right" && !overflowRight(right + tooltipWidth)) {
|
|
83
|
+
return {left: right, top: verticalCenter};
|
|
84
|
+
} else if (
|
|
85
|
+
idealDirection === "bottom" &&
|
|
86
|
+
!overflowBottom &&
|
|
87
|
+
!overflowLeft(horizontalCenter - tooltipWidth) &&
|
|
88
|
+
!overflowRight(horizontalCenter + tooltipWidth)
|
|
89
|
+
) {
|
|
90
|
+
return {left: horizontalCenter - tooltipWidth / 2, top: bottom};
|
|
91
|
+
} else {
|
|
92
|
+
// At this point, we're either trying to place it above or below, and force it into the viewport.
|
|
93
|
+
|
|
94
|
+
let y = top;
|
|
95
|
+
if ((idealDirection === "bottom" && !overflowBottom) || overflowTop) {
|
|
96
|
+
y = bottom;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If it fits in the viewport, center it above the child.
|
|
100
|
+
if (
|
|
101
|
+
!overflowLeft(horizontalCenter - tooltipWidth) &&
|
|
102
|
+
!overflowRight(horizontalCenter + tooltipWidth)
|
|
103
|
+
) {
|
|
104
|
+
return {left: horizontalCenter - tooltipWidth / 2, top: y};
|
|
105
|
+
}
|
|
106
|
+
// Failing that, if it fits on the left, put it there, otherwise to the right. We know it's smaller than the
|
|
107
|
+
// viewport.
|
|
108
|
+
else if (overflowLeft(horizontalCenter - tooltipWidth)) {
|
|
109
|
+
return {left: TOOLTIP_OVERFLOW_PADDING, top: y};
|
|
110
|
+
} else {
|
|
111
|
+
return {
|
|
112
|
+
left: Dimensions.get("window").width - TOOLTIP_OVERFLOW_PADDING - tooltipWidth,
|
|
113
|
+
top: y,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
interface TooltipProps {
|
|
120
|
+
children: React.ReactElement;
|
|
121
|
+
// If text is undefined, the children will be rendered without a tooltip.
|
|
122
|
+
text?: string;
|
|
123
|
+
idealDirection?: "top" | "bottom" | "left" | "right";
|
|
124
|
+
bgColor?: "white" | "lightGray" | "gray" | "darkGray";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const Tooltip = (props: TooltipProps) => {
|
|
128
|
+
const {text, children, bgColor, idealDirection} = props;
|
|
129
|
+
const hoverDelay = 500;
|
|
130
|
+
const hoverEndDelay = 1500;
|
|
131
|
+
const [visible, setVisible] = React.useState(false);
|
|
132
|
+
|
|
133
|
+
const [measurement, setMeasurement] = React.useState({
|
|
134
|
+
children: {},
|
|
135
|
+
tooltip: {},
|
|
136
|
+
measured: false,
|
|
137
|
+
});
|
|
138
|
+
const showTooltipTimer = React.useRef<NodeJS.Timeout>();
|
|
139
|
+
const hideTooltipTimer = React.useRef<NodeJS.Timeout>();
|
|
140
|
+
const childrenWrapperRef = React.useRef() as React.MutableRefObject<View>;
|
|
141
|
+
|
|
142
|
+
const touched = React.useRef(false);
|
|
143
|
+
|
|
144
|
+
const isWeb = Platform.OS === "web";
|
|
145
|
+
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
return () => {
|
|
148
|
+
if (showTooltipTimer.current) {
|
|
149
|
+
clearTimeout(showTooltipTimer.current);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (hideTooltipTimer.current) {
|
|
153
|
+
clearTimeout(hideTooltipTimer.current);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const handleOnLayout = ({nativeEvent: {layout}}: LayoutChangeEvent) => {
|
|
159
|
+
if (childrenWrapperRef?.current && !childrenWrapperRef?.current?.measure) {
|
|
160
|
+
console.error("Tooltip: childrenWrapperRef does not have a measure method.");
|
|
161
|
+
return;
|
|
162
|
+
} else if (!childrenWrapperRef?.current) {
|
|
163
|
+
console.error("Tooltip: childrenWrapperRef is null.");
|
|
164
|
+
}
|
|
165
|
+
childrenWrapperRef?.current?.measure((_x, _y, width, height, pageX, pageY) => {
|
|
166
|
+
setMeasurement({
|
|
167
|
+
children: {pageX, pageY, height, width},
|
|
168
|
+
tooltip: {...layout},
|
|
169
|
+
measured: true,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const handleTouchStart = () => {
|
|
175
|
+
if (hideTooltipTimer.current) {
|
|
176
|
+
clearTimeout(hideTooltipTimer.current);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
showTooltipTimer.current = setTimeout(() => {
|
|
180
|
+
touched.current = true;
|
|
181
|
+
setVisible(true);
|
|
182
|
+
}, 100);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleHoverIn = () => {
|
|
186
|
+
if (hideTooltipTimer.current) {
|
|
187
|
+
clearTimeout(hideTooltipTimer.current);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
showTooltipTimer.current = setTimeout(() => {
|
|
191
|
+
touched.current = true;
|
|
192
|
+
setVisible(true);
|
|
193
|
+
}, hoverDelay);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleHoverOut = () => {
|
|
197
|
+
touched.current = false;
|
|
198
|
+
if (showTooltipTimer.current) {
|
|
199
|
+
clearTimeout(showTooltipTimer.current);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
hideTooltipTimer.current = setTimeout(() => {
|
|
203
|
+
setVisible(false);
|
|
204
|
+
setMeasurement({children: {}, tooltip: {}, measured: false});
|
|
205
|
+
}, hoverEndDelay);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const mobilePressProps = {
|
|
209
|
+
onPress: React.useCallback(() => {
|
|
210
|
+
if (touched.current) {
|
|
211
|
+
return null;
|
|
212
|
+
} else {
|
|
213
|
+
return children.props.onClick?.();
|
|
214
|
+
}
|
|
215
|
+
}, [children.props]),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Allow disabling tooltips when there is no string, otherwise you need to wrap the children in a function to
|
|
219
|
+
// determine if there should be a tooltip or not, which gets messy.
|
|
220
|
+
if (!text) {
|
|
221
|
+
return children;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
{visible && (
|
|
227
|
+
<Portal>
|
|
228
|
+
<Pressable
|
|
229
|
+
style={{
|
|
230
|
+
alignSelf: "flex-start",
|
|
231
|
+
justifyContent: "center",
|
|
232
|
+
paddingHorizontal: 16,
|
|
233
|
+
backgroundColor: Unifier.theme[bgColor ?? "darkGray"],
|
|
234
|
+
borderRadius: 16,
|
|
235
|
+
paddingVertical: 8,
|
|
236
|
+
display: "flex",
|
|
237
|
+
flexShrink: 1,
|
|
238
|
+
maxWidth: Math.max(Dimensions.get("window").width - 32, 300),
|
|
239
|
+
...getTooltipPosition({...(measurement as Measurement), idealDirection}),
|
|
240
|
+
...(measurement.measured ? {opacity: 1} : {opacity: 0}),
|
|
241
|
+
}}
|
|
242
|
+
testID="tooltip-container"
|
|
243
|
+
onLayout={handleOnLayout}
|
|
244
|
+
onPress={() => {
|
|
245
|
+
setVisible(false);
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<Text color="white">{text}</Text>
|
|
249
|
+
</Pressable>
|
|
250
|
+
</Portal>
|
|
251
|
+
)}
|
|
252
|
+
<View
|
|
253
|
+
ref={childrenWrapperRef}
|
|
254
|
+
onPointerEnter={() => {
|
|
255
|
+
handleHoverIn();
|
|
256
|
+
children.props.onHoverIn?.();
|
|
257
|
+
}}
|
|
258
|
+
onPointerLeave={() => {
|
|
259
|
+
handleHoverOut();
|
|
260
|
+
children.props.onHoverOut?.();
|
|
261
|
+
}}
|
|
262
|
+
onTouchStart={handleTouchStart}
|
|
263
|
+
{...(!isWeb && mobilePressProps)}
|
|
264
|
+
>
|
|
265
|
+
{children}
|
|
266
|
+
</View>
|
|
267
|
+
</>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// import {Screens} from "./UnifiedScreens";
|
|
2
|
+
|
|
3
|
+
export enum UnifierScreens {
|
|
4
|
+
// BarcodeView = "BarcodeView",
|
|
5
|
+
Contacts = "Contacts",
|
|
6
|
+
FullPageModal = "FullPageModal",
|
|
7
|
+
Auth = "Auth",
|
|
8
|
+
Spinner = "lib.Spinner",
|
|
9
|
+
Payment = "Payment",
|
|
10
|
+
Onboarding = "Onboarding",
|
|
11
|
+
}
|
|
12
|
+
let initialized = false;
|
|
13
|
+
export function initializeUnifiedScreens() {
|
|
14
|
+
if (initialized) {
|
|
15
|
+
console.error("Cannot initialize screens multiple times.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
initialized = true;
|
|
19
|
+
console.debug("[unifier] Registering unifier screens");
|
|
20
|
+
// Unifier.navigation.registerScreen(UnifierScreens.Auth, Auth, {url: "/auth"});
|
|
21
|
+
// registerScreen(Screens.BarcodeView, BarcodeView, {url: "/barcode"});
|
|
22
|
+
// Unifier.navigation.registerScreen(UnifierScreens.FullPageModal, FullPageModal, {url: "/item"});
|
|
23
|
+
// Unifier.navigation.registerScreen(UnifierScreens.Spinner, Spinner);
|
|
24
|
+
}
|
package/src/Unifier.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
3
|
+
|
|
4
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
5
|
+
import * as Clipboard from "expo-clipboard";
|
|
6
|
+
import * as Font from "expo-font";
|
|
7
|
+
import {FontSource} from "expo-font";
|
|
8
|
+
import * as Haptics from "expo-haptics";
|
|
9
|
+
import {Dimensions, Keyboard, Linking, Platform, Vibration} from "react-native";
|
|
10
|
+
|
|
11
|
+
import {PermissionKind, UnifiedTheme} from "./Common";
|
|
12
|
+
import {requestPermissions} from "./Permissions";
|
|
13
|
+
|
|
14
|
+
function capitalize(string: string) {
|
|
15
|
+
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FONT = "Cochin";
|
|
19
|
+
const DEFAULT_BOLD_FONT = "Cochin";
|
|
20
|
+
|
|
21
|
+
const DefaultTheme: UnifiedTheme = {
|
|
22
|
+
// Primary colors
|
|
23
|
+
red: "#bd081c",
|
|
24
|
+
white: "#fdfdfd",
|
|
25
|
+
lightGray: "#efefef",
|
|
26
|
+
gray: "#8e8e8e",
|
|
27
|
+
darkGray: "#111",
|
|
28
|
+
// secondary colors
|
|
29
|
+
green: "#0fa573",
|
|
30
|
+
springGreen: "#008753",
|
|
31
|
+
pine: "#0a6955",
|
|
32
|
+
olive: "#364a4c",
|
|
33
|
+
blue: "#4a90e2",
|
|
34
|
+
navy: "#004b91",
|
|
35
|
+
midnight: "#133a5e",
|
|
36
|
+
purple: "#b469eb",
|
|
37
|
+
orchid: "#8046a5",
|
|
38
|
+
eggplant: "#5b2677",
|
|
39
|
+
maroon: "#6e0f3c",
|
|
40
|
+
watermelon: "#f13535",
|
|
41
|
+
orange: "#e3780c",
|
|
42
|
+
black: "#000000",
|
|
43
|
+
|
|
44
|
+
primaryLighter: "#4ED456",
|
|
45
|
+
primaryLight: "#28CA32",
|
|
46
|
+
primary: "#00BD0C",
|
|
47
|
+
primaryDark: "#00960A",
|
|
48
|
+
primaryDarker: "#007508",
|
|
49
|
+
|
|
50
|
+
secondaryLighter: "#41AAAE",
|
|
51
|
+
secondaryLight: "#20989D",
|
|
52
|
+
secondary: "#018B91",
|
|
53
|
+
secondaryDark: "#016E73",
|
|
54
|
+
secondaryDarker: "#00565A",
|
|
55
|
+
|
|
56
|
+
accentLighter: "#FF625D",
|
|
57
|
+
accentLight: "#FF3732",
|
|
58
|
+
accent: "#F00600",
|
|
59
|
+
accentDark: "#BE0500",
|
|
60
|
+
accentDarker: "#950400",
|
|
61
|
+
|
|
62
|
+
tertiaryLighter: "#FFCF67",
|
|
63
|
+
tertiaryLight: "#FFC23E",
|
|
64
|
+
tertiary: "#FFB109",
|
|
65
|
+
tertiaryDark: "#CA8A00",
|
|
66
|
+
tertiaryDarker: "#9F6D00",
|
|
67
|
+
|
|
68
|
+
// From the Atlassian templates
|
|
69
|
+
neutral900: "#091E42",
|
|
70
|
+
neutral800: "#172B4D",
|
|
71
|
+
neutral700: "#253858",
|
|
72
|
+
neutral600: "#344563",
|
|
73
|
+
neutral500: "#42526E",
|
|
74
|
+
neutral400: "#505F79",
|
|
75
|
+
neutral300: "#5E6C84",
|
|
76
|
+
neutral200: "#6B778C",
|
|
77
|
+
neutral100: "#7A869A",
|
|
78
|
+
neutral90: "#8993A4",
|
|
79
|
+
neutral80: "#97A0AF",
|
|
80
|
+
neutral70: "#A5ADBA",
|
|
81
|
+
neutral60: "#B3BAC5",
|
|
82
|
+
neutral50: "#C1C7D0",
|
|
83
|
+
neutral40: "#DFE1E6",
|
|
84
|
+
neutral30: "#EBECF0",
|
|
85
|
+
neutral20: "#F4F5F7",
|
|
86
|
+
neutral10: "#FAFBFC",
|
|
87
|
+
|
|
88
|
+
primaryFont: DEFAULT_FONT,
|
|
89
|
+
primaryBoldFont: DEFAULT_BOLD_FONT,
|
|
90
|
+
|
|
91
|
+
secondaryFont: DEFAULT_FONT,
|
|
92
|
+
secondaryBoldFont: DEFAULT_BOLD_FONT,
|
|
93
|
+
|
|
94
|
+
accentFont: DEFAULT_FONT,
|
|
95
|
+
accentBoldFont: DEFAULT_BOLD_FONT,
|
|
96
|
+
|
|
97
|
+
buttonFont: DEFAULT_FONT,
|
|
98
|
+
titleFont: DEFAULT_FONT,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type PlatformOS = "ios" | "android" | "web";
|
|
102
|
+
|
|
103
|
+
const fontKeys = [
|
|
104
|
+
"primaryFont",
|
|
105
|
+
"primaryBoldFont",
|
|
106
|
+
"secondaryFont",
|
|
107
|
+
"secondaryBoldFont",
|
|
108
|
+
"buttonFont",
|
|
109
|
+
"accentFont",
|
|
110
|
+
"accentBoldFont",
|
|
111
|
+
"titleFont",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
type Luminance = "light" | "lighter" | "dark" | "darker";
|
|
115
|
+
const luminances: Luminance[] = ["lighter", "light", "dark", "darker"];
|
|
116
|
+
|
|
117
|
+
// Changes a color luminance
|
|
118
|
+
export function changeColorLuminance(hex: string, luminanceChange: Luminance) {
|
|
119
|
+
// Validate hex string, strip "#" if present.
|
|
120
|
+
hex = String(hex).replace(/[^0-9a-f]/gi, "");
|
|
121
|
+
if (hex.length === 3) {
|
|
122
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
123
|
+
} else if (hex.length !== 6) {
|
|
124
|
+
throw new Error(`Invalid color hex: ${hex}`);
|
|
125
|
+
}
|
|
126
|
+
let luminance;
|
|
127
|
+
switch (luminanceChange) {
|
|
128
|
+
case "light":
|
|
129
|
+
luminance = -0.2;
|
|
130
|
+
break;
|
|
131
|
+
case "lighter":
|
|
132
|
+
luminance = -0.33;
|
|
133
|
+
break;
|
|
134
|
+
case "dark":
|
|
135
|
+
luminance = 0.2;
|
|
136
|
+
break;
|
|
137
|
+
case "darker":
|
|
138
|
+
luminance = 0.33;
|
|
139
|
+
break;
|
|
140
|
+
default:
|
|
141
|
+
throw new Error(`Cannot change luminance to ${luminanceChange}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Convert to decimal and change luminosity
|
|
145
|
+
let rgb = "#";
|
|
146
|
+
for (let i = 0; i < 3; i++) {
|
|
147
|
+
const decimal = parseInt(hex.substr(i * 2, 2), 16);
|
|
148
|
+
const appliedLuminance = Math.round(
|
|
149
|
+
Math.min(Math.max(0, decimal + decimal * luminance), 255)
|
|
150
|
+
).toString(16);
|
|
151
|
+
// 0 pad, if necessary.
|
|
152
|
+
rgb += `00${appliedLuminance}`.substr(appliedLuminance.length);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return rgb;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class UnifierClass {
|
|
159
|
+
private _theme?: Partial<UnifiedTheme>;
|
|
160
|
+
|
|
161
|
+
private _web = false;
|
|
162
|
+
|
|
163
|
+
private _dev = false;
|
|
164
|
+
|
|
165
|
+
get web(): boolean {
|
|
166
|
+
return this._web;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get dev(): boolean {
|
|
170
|
+
return this._dev;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private get fontMap() {
|
|
174
|
+
const fontMap: {[id: string]: FontSource} = {};
|
|
175
|
+
for (const key of fontKeys) {
|
|
176
|
+
if (typeof this.theme[key as keyof typeof Unifier.theme] === "string") {
|
|
177
|
+
fontMap[key] = key;
|
|
178
|
+
} else {
|
|
179
|
+
fontMap[(this.theme as any)[key].name] = (this.theme as any)[key].source;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return fontMap;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If the theme only has e.g. "primary" set, calculate the primaryLighter, primaryLight, etc based on that color.
|
|
186
|
+
private calculateLuminances(
|
|
187
|
+
theme: Partial<UnifiedTheme>,
|
|
188
|
+
color: "primary" | "secondary" | "accent" | "tertiary"
|
|
189
|
+
) {
|
|
190
|
+
if (!theme[color]) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
for (const luminance of luminances) {
|
|
194
|
+
const capitalized = capitalize(luminance);
|
|
195
|
+
if (!theme[`${color}${capitalized}` as keyof typeof Unifier.theme] && theme[color]) {
|
|
196
|
+
theme[`${color}${capitalized}` as keyof typeof Unifier.theme] = changeColorLuminance(
|
|
197
|
+
theme[color] as string,
|
|
198
|
+
luminance
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setTheme(theme: Partial<UnifiedTheme>) {
|
|
205
|
+
Unifier.calculateLuminances(theme, "primary");
|
|
206
|
+
Unifier.calculateLuminances(theme, "secondary");
|
|
207
|
+
Unifier.calculateLuminances(theme, "accent");
|
|
208
|
+
Unifier.calculateLuminances(theme, "tertiary");
|
|
209
|
+
this._theme = theme;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get theme(): UnifiedTheme {
|
|
213
|
+
return {
|
|
214
|
+
...DefaultTheme,
|
|
215
|
+
// Custom per project
|
|
216
|
+
primaryLighter: this._theme?.primaryLighter || this._theme?.primary || DefaultTheme.primary,
|
|
217
|
+
primaryLight: this._theme?.primaryLight || this._theme?.primary || DefaultTheme.primary,
|
|
218
|
+
primary: this._theme?.primary || this._theme?.primary || DefaultTheme.primary,
|
|
219
|
+
primaryDark: this._theme?.primaryDark || this._theme?.primary || DefaultTheme.primary,
|
|
220
|
+
primaryDarker: this._theme?.primaryDarker || this._theme?.primary || DefaultTheme.primary,
|
|
221
|
+
|
|
222
|
+
secondaryLighter:
|
|
223
|
+
this._theme?.secondaryLighter || this._theme?.secondary || DefaultTheme.secondary,
|
|
224
|
+
secondaryLight:
|
|
225
|
+
this._theme?.secondaryLight || this._theme?.secondary || DefaultTheme.secondary,
|
|
226
|
+
secondary: this._theme?.secondary || this._theme?.secondary || DefaultTheme.secondary,
|
|
227
|
+
secondaryDark: this._theme?.secondaryDark || this._theme?.secondary || DefaultTheme.secondary,
|
|
228
|
+
secondaryDarker:
|
|
229
|
+
this._theme?.secondaryDarker || this._theme?.secondary || DefaultTheme.secondary,
|
|
230
|
+
|
|
231
|
+
accentLighter: this._theme?.accentLighter || this._theme?.accent || DefaultTheme.accent,
|
|
232
|
+
accentLight: this._theme?.accentLight || this._theme?.accent || DefaultTheme.accent,
|
|
233
|
+
accent: this._theme?.accent || this._theme?.accent || DefaultTheme.accent,
|
|
234
|
+
accentDark: this._theme?.accentDark || this._theme?.accent || DefaultTheme.accent,
|
|
235
|
+
accentDarker: this._theme?.accentDarker || this._theme?.accent || DefaultTheme.accent,
|
|
236
|
+
|
|
237
|
+
tertiaryLighter: this._theme?.tertiaryLighter || this._theme?.accent || DefaultTheme.accent,
|
|
238
|
+
tertiaryLight: this._theme?.tertiaryLight || this._theme?.accent || DefaultTheme.accent,
|
|
239
|
+
tertiary: this._theme?.tertiary || this._theme?.accent || DefaultTheme.accent,
|
|
240
|
+
tertiaryDark: this._theme?.tertiaryDark || this._theme?.accent || DefaultTheme.accent,
|
|
241
|
+
tertiaryDarker: this._theme?.tertiaryDarker || this._theme?.accent || DefaultTheme.accent,
|
|
242
|
+
|
|
243
|
+
primaryFont: this._theme?.primaryFont || DefaultTheme.primaryFont,
|
|
244
|
+
primaryBoldFont:
|
|
245
|
+
this._theme?.primaryBoldFont || this._theme?.primaryFont || DefaultTheme.primaryBoldFont,
|
|
246
|
+
secondaryFont:
|
|
247
|
+
this._theme?.secondaryFont || this._theme?.primaryFont || DefaultTheme.secondaryFont,
|
|
248
|
+
secondaryBoldFont:
|
|
249
|
+
this._theme?.secondaryBoldFont ||
|
|
250
|
+
this._theme?.primaryFont ||
|
|
251
|
+
DefaultTheme.secondaryBoldFont,
|
|
252
|
+
buttonFont: this._theme?.buttonFont || this._theme?.primaryFont || DefaultTheme.buttonFont,
|
|
253
|
+
accentFont: this._theme?.accentFont || this._theme?.primaryFont || DefaultTheme.accentFont,
|
|
254
|
+
accentBoldFont:
|
|
255
|
+
this._theme?.accentBoldFont || this._theme?.primaryFont || DefaultTheme.accentBoldFont,
|
|
256
|
+
titleFont: this._theme?.titleFont || this._theme?.primaryFont || DefaultTheme.titleFont,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
constructor() {
|
|
261
|
+
console.debug("[unifier] Setting up Unifier");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
navigation = {
|
|
265
|
+
dismissOverlay: () => {
|
|
266
|
+
console.warn("Dismiss overlay not supported.");
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
loadFonts = async () => {
|
|
271
|
+
try {
|
|
272
|
+
await Font.loadAsync(this.fontMap);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(`[unifier] Fonts failed to load: ${err}`);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// tracking: Tracking,
|
|
279
|
+
utils = {
|
|
280
|
+
dismissKeyboard: () => {
|
|
281
|
+
Keyboard.dismiss();
|
|
282
|
+
},
|
|
283
|
+
dimensions: () => ({
|
|
284
|
+
width: Dimensions.get("window").width,
|
|
285
|
+
height: Dimensions.get("window").height,
|
|
286
|
+
}),
|
|
287
|
+
copyToClipboard: (text: string) => {
|
|
288
|
+
Clipboard.setString(text);
|
|
289
|
+
},
|
|
290
|
+
orientationChange: (callback: (orientation: "portrait" | "landscape") => void) => {
|
|
291
|
+
Dimensions.addEventListener("change", () => {
|
|
292
|
+
const screen = Dimensions.get("screen");
|
|
293
|
+
const isPortrait = screen.width < screen.height;
|
|
294
|
+
callback(isPortrait ? "portrait" : "landscape");
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
requestPermissions: async (_perm: PermissionKind) => {
|
|
298
|
+
return requestPermissions(_perm);
|
|
299
|
+
// return requestPermissions(perm);
|
|
300
|
+
},
|
|
301
|
+
makePurchase: () => {
|
|
302
|
+
console.warn("Make purchase not supported yet.");
|
|
303
|
+
},
|
|
304
|
+
PaymentService: () => {
|
|
305
|
+
console.warn("Make purchase not supported yet.");
|
|
306
|
+
},
|
|
307
|
+
vibrate: (pattern?: number[]) => {
|
|
308
|
+
Vibration.vibrate(pattern || [100], false);
|
|
309
|
+
},
|
|
310
|
+
haptic: () => {
|
|
311
|
+
if (Platform.OS !== "web") {
|
|
312
|
+
return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
},
|
|
316
|
+
openUrl: async (url: string) => {
|
|
317
|
+
return Linking.openURL(url);
|
|
318
|
+
},
|
|
319
|
+
// keepAwake: (activate: boolean) => {
|
|
320
|
+
// if (activate) {
|
|
321
|
+
// activateKeepAwake();
|
|
322
|
+
// } else {
|
|
323
|
+
// deactivateKeepAwake();
|
|
324
|
+
// }
|
|
325
|
+
// },
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
storage = {
|
|
329
|
+
getItem: async (key: string, defaultValue?: any) => {
|
|
330
|
+
try {
|
|
331
|
+
const jsonValue = await AsyncStorage.getItem(key);
|
|
332
|
+
if (jsonValue) {
|
|
333
|
+
const value = JSON.parse(jsonValue);
|
|
334
|
+
if (value === null || value === undefined) {
|
|
335
|
+
return defaultValue;
|
|
336
|
+
} else {
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
} else if (defaultValue !== undefined) {
|
|
340
|
+
return defaultValue;
|
|
341
|
+
} else {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.error(`[storage] Error reading ${key}`, e);
|
|
346
|
+
return defaultValue || null;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
setItem: async (key: string, item: any) => {
|
|
350
|
+
try {
|
|
351
|
+
const jsonValue = JSON.stringify(item);
|
|
352
|
+
await AsyncStorage.setItem(key, jsonValue);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.error(`[storage] Error storing ${key}`, item, e);
|
|
355
|
+
throw new Error(e as any);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
tracking = {
|
|
361
|
+
log: (message: string) => {
|
|
362
|
+
console.info(message);
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
initIcons = () => {
|
|
367
|
+
console.debug("[unifier] Initializing icons");
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const Unifier = new UnifierClass();
|