@applicaster/quick-brick-core 16.0.0-rc.6 → 16.0.0-rc.60

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.
Files changed (26) hide show
  1. package/App/ActionsProvider/ActionsProvider.tsx +31 -9
  2. package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +107 -0
  3. package/App/ModalProvider/ModalBottomSheet/DraggableBottomSheet/__tests__/index.test.tsx +88 -0
  4. package/App/ModalProvider/ModalBottomSheet/DraggableBottomSheet/index.tsx +30 -56
  5. package/App/ModalProvider/ModalBottomSheet/ModalBottomSheetFrame.tsx +9 -2
  6. package/App/ModalProvider/ModalBottomSheet/hooks/__tests__/useKeyboardHeight.android.test.ts +105 -0
  7. package/App/ModalProvider/ModalBottomSheet/hooks/__tests__/useKeyboardHeight.test.ts +102 -0
  8. package/App/ModalProvider/ModalBottomSheet/hooks/index.ts +1 -0
  9. package/App/ModalProvider/ModalBottomSheet/hooks/useKeyboardHeight.ts +30 -0
  10. package/App/ModalProvider/ModalBottomSheet/index.tsx +5 -2
  11. package/App/NotificationToastRenderer/NotificationToastManager.ts +214 -0
  12. package/App/NotificationToastRenderer/NotificationToastRenderer.tsx +134 -0
  13. package/App/NotificationToastRenderer/NotificationToastRenderer.tv.tsx +131 -0
  14. package/App/NotificationToastRenderer/NotificationToastRenderer.web.tsx +61 -0
  15. package/App/NotificationToastRenderer/__tests__/NotificationToastManager.test.ts +237 -0
  16. package/App/NotificationToastRenderer/__tests__/NotificationToastRenderer.test.tsx +233 -0
  17. package/App/NotificationToastRenderer/__tests__/NotificationToastRenderer.web.test.tsx +90 -0
  18. package/App/NotificationToastRenderer/__tests__/resolveConfirmationToastStyle.test.ts +118 -0
  19. package/App/NotificationToastRenderer/__tests__/useNotificationHeight.test.tsx +58 -0
  20. package/App/NotificationToastRenderer/__tests__/useNotificationToastState.test.ts +112 -0
  21. package/App/NotificationToastRenderer/index.tsx +11 -0
  22. package/App/NotificationToastRenderer/resolveConfirmationToastStyle.ts +33 -0
  23. package/App/NotificationToastRenderer/useNotificationHeight.tsx +13 -0
  24. package/App/NotificationToastRenderer/useNotificationToastState.tsx +12 -0
  25. package/App/index.tsx +8 -5
  26. package/package.json +8 -8
@@ -0,0 +1,30 @@
1
+ import React from "react";
2
+ import { Keyboard, KeyboardEvent } from "react-native";
3
+ import { isApplePlatform } from "@applicaster/zapp-react-native-utils/reactUtils";
4
+
5
+ const isApple = isApplePlatform();
6
+
7
+ export function useKeyboardHeight() {
8
+ const [keyboardHeight, setKeyboardHeight] = React.useState(0);
9
+
10
+ React.useEffect(() => {
11
+ // iOS supports smooth 'willShow' / 'willHide' events
12
+ const showEvent = isApple ? "keyboardWillShow" : "keyboardDidShow";
13
+ const hideEvent = isApple ? "keyboardWillHide" : "keyboardDidHide";
14
+
15
+ const showListener = Keyboard.addListener(showEvent, (e: KeyboardEvent) => {
16
+ setKeyboardHeight(e.endCoordinates.height);
17
+ });
18
+
19
+ const hideListener = Keyboard.addListener(hideEvent, () => {
20
+ setKeyboardHeight(0);
21
+ });
22
+
23
+ return () => {
24
+ showListener.remove();
25
+ hideListener.remove();
26
+ };
27
+ }, []);
28
+
29
+ return keyboardHeight;
30
+ }
@@ -47,9 +47,11 @@ export function ModalBottomSheet(props: Props) {
47
47
 
48
48
  const {
49
49
  ModalBottomSheetContent = ModalComponent,
50
- modalBottomSheetContentProps,
50
+ modalBottomSheetContentProps = {},
51
51
  } = props;
52
52
 
53
+ const { key, ...restContentProps } = modalBottomSheetContentProps;
54
+
53
55
  useEffect(() => {
54
56
  Keyboard.dismiss();
55
57
  }, []);
@@ -82,11 +84,12 @@ export function ModalBottomSheet(props: Props) {
82
84
  }}
83
85
  >
84
86
  <ModalBottomSheetContent
87
+ key={key as any}
85
88
  width={
86
89
  Math.min(sheetTabletMaxWidth, dimensions.width) || dimensions.width
87
90
  }
88
91
  maxHeight={sheetMaxHeight}
89
- {...modalBottomSheetContentProps}
92
+ {...restContentProps}
90
93
  dismiss={dismiss}
91
94
  currentRoute={navigator.currentRoute}
92
95
  />
@@ -0,0 +1,214 @@
1
+ import { subscriber } from "@applicaster/zapp-react-native-utils/functionUtils";
2
+ import { subscriberFor } from "@applicaster/zapp-react-native-utils/reactHooks/useSubscriberFor";
3
+
4
+ // events accepted on the shared cross-package event bus (see useSubscriberFor),
5
+ // so packages that must not depend on quick-brick-core (e.g. OfflineHandler in
6
+ // zapp-react-native-ui-components) can trigger a toast by event name alone,
7
+ // without importing notificationToastManager directly.
8
+ const unwrapEventPayload = (payload: unknown) =>
9
+ Array.isArray(payload) ? payload[0] : payload;
10
+
11
+ export const NOTIFICATION_TOAST_EVENTS = {
12
+ SHOW_TOAST: "showToast", // in: request to enqueue a toast
13
+ DISMISS: "dismiss", // in: renderer/user dismissed the visible toast early
14
+ SHOW: "show", // out: renderer should display this toast
15
+ HIDE: "hide", // out: renderer should hide the current toast
16
+ } as const;
17
+
18
+ export interface ToastStyle {
19
+ backgroundColor?: string;
20
+ color?: string;
21
+ fontFamily?: string;
22
+ fontSize?: number;
23
+ lineHeight?: number;
24
+ letterSpacing?: number;
25
+ }
26
+
27
+ export const DEFAULT_TOAST_TIMEOUT = 4000;
28
+
29
+ export const CONFIRMATION_TOAST_SOURCE = "confirmation";
30
+
31
+ export interface ToastPayload {
32
+ id?: string;
33
+ message: string;
34
+ extraMessage?: string; // secondary line; TV renderers show it as a subtitle, mobile ignores it
35
+ timeout?: number; // ms; defaults to DEFAULT_TOAST_TIMEOUT (4000) if omitted
36
+ style?: ToastStyle;
37
+ // stamped by the showToast action; Theme confirmation styles apply when
38
+ // source === CONFIRMATION_TOAST_SOURCE ("confirmation")
39
+ source?: string;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ class NotificationToastManager {
44
+ private static instance: NotificationToastManager;
45
+
46
+ private eventHandler = subscriber();
47
+ private queue: ToastPayload[] = [];
48
+ private hideTimer: ReturnType<typeof setTimeout> | null = null;
49
+ private isShowing = false;
50
+ private nextId = 0;
51
+
52
+ private constructor() {
53
+ // subscribe to the shared event bus so other packages can trigger toasts
54
+ subscriberFor(
55
+ NOTIFICATION_TOAST_EVENTS.SHOW_TOAST,
56
+ this.handleShowToastEvent
57
+ );
58
+
59
+ subscriberFor(NOTIFICATION_TOAST_EVENTS.DISMISS, this.handleDismiss);
60
+
61
+ this.getCurrentState = this.getCurrentState.bind(this);
62
+ this.subscribe = this.subscribe.bind(this);
63
+ }
64
+
65
+ static getInstance(): NotificationToastManager {
66
+ if (!NotificationToastManager.instance) {
67
+ NotificationToastManager.instance = new NotificationToastManager();
68
+ }
69
+
70
+ return NotificationToastManager.instance;
71
+ }
72
+
73
+ on(event: string, callback: (...args: any[]) => void) {
74
+ this.eventHandler.on(event, callback);
75
+ }
76
+
77
+ removeHandler(event: string, callback: (...args: any[]) => void) {
78
+ this.eventHandler.removeHandler(event, callback);
79
+ }
80
+
81
+ showToast(payload: ToastPayload) {
82
+ this.handleShowToast(payload);
83
+ }
84
+
85
+ dismiss() {
86
+ this.handleDismiss();
87
+ }
88
+
89
+ getCurrentState() {
90
+ return this.queue[0] ?? null;
91
+ }
92
+
93
+ subscribe(callback) {
94
+ this.eventHandler.on(NOTIFICATION_TOAST_EVENTS.SHOW, callback);
95
+
96
+ this.eventHandler.on(NOTIFICATION_TOAST_EVENTS.HIDE, callback);
97
+
98
+ return () => {
99
+ this.eventHandler.removeHandler(NOTIFICATION_TOAST_EVENTS.SHOW, callback);
100
+
101
+ this.eventHandler.removeHandler(NOTIFICATION_TOAST_EVENTS.HIDE, callback);
102
+ };
103
+ }
104
+
105
+ // subscriberFor's shared bus can deliver the raw payload (component emitters,
106
+ // see useSubscriberFor) or an args array (postEvent-style callers) - accept both.
107
+ private handleShowToastEvent = (payload: unknown) => {
108
+ if (!payload) {
109
+ console.warn(
110
+ "NotificationToastManager: Invalid showToast payload:",
111
+ payload
112
+ );
113
+
114
+ return;
115
+ }
116
+
117
+ const unwrappedPayload = unwrapEventPayload(payload) as ToastPayload;
118
+
119
+ if (!unwrappedPayload || typeof unwrappedPayload.message !== "string") {
120
+ console.warn(
121
+ "NotificationToastManager: Invalid showToast payload:",
122
+ payload
123
+ );
124
+
125
+ return;
126
+ }
127
+
128
+ this.handleShowToast(unwrappedPayload);
129
+ };
130
+
131
+ private handleShowToast = (payload: ToastPayload) => {
132
+ const item = {
133
+ ...payload,
134
+ id: payload.id ?? String(this.nextId++),
135
+ timeout: payload.timeout ?? DEFAULT_TOAST_TIMEOUT,
136
+ };
137
+
138
+ const queuedIndex = this.queue.findIndex((queued) => queued.id === item.id);
139
+
140
+ if (queuedIndex === -1) {
141
+ this.queue.push(item);
142
+ } else if (queuedIndex === 0 && this.isShowing) {
143
+ // this id is the toast currently on screen - swap its content in place
144
+ // (new one appears in front of it) and dismiss the old timer/content
145
+ this.clearTimer();
146
+ this.queue[0] = item;
147
+ this.eventHandler.invokeHandler(NOTIFICATION_TOAST_EVENTS.SHOW, item);
148
+
149
+ if (item.timeout) {
150
+ this.hideTimer = setTimeout(() => this.finishCurrent(), item.timeout);
151
+ }
152
+
153
+ return;
154
+ } else {
155
+ // still queued, not yet shown - update it in place instead of duplicating
156
+ this.queue[queuedIndex] = item;
157
+
158
+ return;
159
+ }
160
+
161
+ if (!this.isShowing) {
162
+ this.processNext();
163
+ }
164
+ };
165
+
166
+ private handleDismiss = () => {
167
+ if (!this.isShowing) {
168
+ return;
169
+ }
170
+
171
+ this.finishCurrent();
172
+ };
173
+
174
+ private processNext() {
175
+ const next = this.queue[0];
176
+
177
+ if (!next) {
178
+ this.isShowing = false;
179
+
180
+ return;
181
+ }
182
+
183
+ this.isShowing = true;
184
+ this.eventHandler.invokeHandler(NOTIFICATION_TOAST_EVENTS.SHOW, next);
185
+
186
+ if (next.timeout) {
187
+ this.hideTimer = setTimeout(() => this.finishCurrent(), next.timeout);
188
+ }
189
+ }
190
+
191
+ private finishCurrent() {
192
+ this.clearTimer();
193
+ this.queue.shift();
194
+ this.eventHandler.invokeHandler(NOTIFICATION_TOAST_EVENTS.HIDE);
195
+ this.isShowing = false;
196
+ this.processNext();
197
+ }
198
+
199
+ private clearTimer() {
200
+ if (this.hideTimer) {
201
+ clearTimeout(this.hideTimer);
202
+ this.hideTimer = null;
203
+ }
204
+ }
205
+
206
+ public reset(): void {
207
+ this.clearTimer();
208
+ this.queue = [];
209
+ this.isShowing = false;
210
+ this.nextId = 0;
211
+ }
212
+ }
213
+
214
+ export const notificationToastManager = NotificationToastManager.getInstance();
@@ -0,0 +1,134 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, Text, Animated, StyleSheet, ViewStyle } from "react-native";
3
+ import {
4
+ Directions,
5
+ Gesture,
6
+ GestureDetector,
7
+ } from "react-native-gesture-handler";
8
+ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
9
+
10
+ import {
11
+ CONFIRMATION_TOAST_SOURCE,
12
+ notificationToastManager,
13
+ } from "./NotificationToastManager";
14
+ import { useNotificationToastState } from "./useNotificationToastState";
15
+ import { useNotificationHeight } from "./useNotificationHeight";
16
+ import { resolveConfirmationToastStyle } from "./resolveConfirmationToastStyle";
17
+
18
+ const DEFAULT_TOAST_BACKGROUND_COLOR = "#000";
19
+ const DEFAULT_TOAST_MESSAGE_COLOR = "#fff";
20
+
21
+ const styles = StyleSheet.create({
22
+ notificationView: {
23
+ flex: 1,
24
+ position: "absolute",
25
+ alignItems: "center",
26
+ justifyContent: "center",
27
+ top: 0,
28
+ left: 0,
29
+ right: 0,
30
+ },
31
+ });
32
+
33
+ type NumberBoolean = 0 | 1;
34
+
35
+ export const NotificationToastRenderer = ({ children }) => {
36
+ const { statusHeight, notificationHeight } = useNotificationHeight();
37
+ const theme = useTheme<BaseThemePropertiesMobile>();
38
+
39
+ const toast = useNotificationToastState();
40
+
41
+ const [notificationOpacity, setNotificationOpacity] =
42
+ useState<NumberBoolean>(0);
43
+
44
+ const yPosition = useRef(new Animated.Value(0)).current;
45
+
46
+ const resolvedStyle =
47
+ toast?.source === CONFIRMATION_TOAST_SOURCE
48
+ ? resolveConfirmationToastStyle(theme, toast.style)
49
+ : toast?.style;
50
+
51
+ const backgroundColor =
52
+ resolvedStyle?.backgroundColor ?? DEFAULT_TOAST_BACKGROUND_COLOR;
53
+
54
+ const color = resolvedStyle?.color ?? DEFAULT_TOAST_MESSAGE_COLOR;
55
+
56
+ const textStyles = {
57
+ color,
58
+ fontFamily: resolvedStyle?.fontFamily,
59
+ fontSize: resolvedStyle?.fontSize,
60
+ lineHeight: resolvedStyle?.lineHeight,
61
+ letterSpacing: resolvedStyle?.letterSpacing,
62
+ };
63
+
64
+ const animatedViewStyles: ViewStyle = {
65
+ ...styles.notificationView,
66
+ transform: [
67
+ {
68
+ translateY: yPosition.interpolate({
69
+ inputRange: [0, 1],
70
+ outputRange: [-notificationHeight, 0],
71
+ }),
72
+ },
73
+ ],
74
+ opacity: notificationOpacity,
75
+ height: notificationHeight,
76
+ width: "100%",
77
+
78
+ backgroundColor,
79
+ };
80
+
81
+ const flingGesture = useMemo(
82
+ () =>
83
+ Gesture.Fling()
84
+ .direction(Directions.UP)
85
+ .enabled(!!toast)
86
+ .runOnJS(true)
87
+ .onEnd((_event, success) => {
88
+ if (success) {
89
+ notificationToastManager.dismiss();
90
+ }
91
+ }),
92
+ [toast]
93
+ );
94
+
95
+ useEffect(() => {
96
+ if (toast) {
97
+ setNotificationOpacity(1);
98
+
99
+ Animated.timing(yPosition, {
100
+ toValue: 1,
101
+ useNativeDriver: true,
102
+ duration: 300,
103
+ }).start();
104
+ } else {
105
+ Animated.timing(yPosition, {
106
+ toValue: 0,
107
+ useNativeDriver: true,
108
+ duration: 300,
109
+ }).start(() => setNotificationOpacity(0));
110
+ }
111
+ }, [toast]);
112
+
113
+ return (
114
+ <View
115
+ testID="NotificationToastRenderer"
116
+ style={StyleSheet.absoluteFill}
117
+ pointerEvents="box-none"
118
+ >
119
+ {children}
120
+ <GestureDetector gesture={flingGesture}>
121
+ <Animated.View
122
+ testID="NotificationToastRenderer-fling"
123
+ pointerEvents={toast ? "auto" : "none"}
124
+ style={animatedViewStyles}
125
+ >
126
+ <View style={{ height: statusHeight, backgroundColor }} />
127
+ <Text testID="NotificationToastRenderer-message" style={textStyles}>
128
+ {toast?.message}
129
+ </Text>
130
+ </Animated.View>
131
+ </GestureDetector>
132
+ </View>
133
+ );
134
+ };
@@ -0,0 +1,131 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { View, Text, Animated, StyleSheet, ViewStyle } from "react-native";
3
+ import {
4
+ Directions,
5
+ FlingGestureHandler,
6
+ State,
7
+ } from "react-native-gesture-handler";
8
+ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
9
+
10
+ import {
11
+ CONFIRMATION_TOAST_SOURCE,
12
+ notificationToastManager,
13
+ } from "./NotificationToastManager";
14
+ import { useNotificationToastState } from "./useNotificationToastState";
15
+ import { useNotificationHeight } from "./useNotificationHeight";
16
+ import { resolveConfirmationToastStyle } from "./resolveConfirmationToastStyle";
17
+
18
+ const DEFAULT_TOAST_BACKGROUND_COLOR = "#000";
19
+ const DEFAULT_TOAST_MESSAGE_COLOR = "#fff";
20
+
21
+ const styles = StyleSheet.create({
22
+ notificationView: {
23
+ flex: 1,
24
+ position: "absolute",
25
+ alignItems: "center",
26
+ justifyContent: "center",
27
+ top: 0,
28
+ left: 0,
29
+ right: 0,
30
+ },
31
+ });
32
+
33
+ type NumberBoolean = 0 | 1;
34
+
35
+ export const NotificationToastRenderer = ({ children }) => {
36
+ const { statusHeight, notificationHeight } = useNotificationHeight();
37
+ const theme = useTheme<BaseThemePropertiesMobile>();
38
+
39
+ const toast = useNotificationToastState();
40
+
41
+ const [notificationOpacity, setNotificationOpacity] =
42
+ useState<NumberBoolean>(0);
43
+
44
+ const yPosition = useRef(new Animated.Value(0)).current;
45
+
46
+ const resolvedStyle =
47
+ toast?.source === CONFIRMATION_TOAST_SOURCE
48
+ ? resolveConfirmationToastStyle(theme, toast.style)
49
+ : toast?.style;
50
+
51
+ const backgroundColor =
52
+ resolvedStyle?.backgroundColor ?? DEFAULT_TOAST_BACKGROUND_COLOR;
53
+
54
+ const color = resolvedStyle?.color ?? DEFAULT_TOAST_MESSAGE_COLOR;
55
+
56
+ const textStyles = {
57
+ color,
58
+ fontFamily: resolvedStyle?.fontFamily,
59
+ fontSize: resolvedStyle?.fontSize,
60
+ lineHeight: resolvedStyle?.lineHeight,
61
+ letterSpacing: resolvedStyle?.letterSpacing,
62
+ };
63
+
64
+ const animatedViewStyles: ViewStyle = {
65
+ ...styles.notificationView,
66
+ transform: [
67
+ {
68
+ translateY: yPosition.interpolate({
69
+ inputRange: [0, 1],
70
+ outputRange: [-notificationHeight, 0],
71
+ }),
72
+ },
73
+ ],
74
+ opacity: notificationOpacity,
75
+ height: notificationHeight,
76
+ width: "100%",
77
+
78
+ backgroundColor,
79
+ };
80
+
81
+ const onFlingStateChange = useCallback(({ nativeEvent }) => {
82
+ // Fling activates then ends; dismiss once the upward fling is recognized.
83
+ if (nativeEvent.oldState === State.ACTIVE) {
84
+ notificationToastManager.dismiss();
85
+ }
86
+ }, []);
87
+
88
+ useEffect(() => {
89
+ if (toast) {
90
+ setNotificationOpacity(1);
91
+
92
+ Animated.timing(yPosition, {
93
+ toValue: 1,
94
+ useNativeDriver: true,
95
+ duration: 300,
96
+ }).start();
97
+ } else {
98
+ Animated.timing(yPosition, {
99
+ toValue: 0,
100
+ useNativeDriver: true,
101
+ duration: 300,
102
+ }).start(() => setNotificationOpacity(0));
103
+ }
104
+ }, [toast]);
105
+
106
+ return (
107
+ <View
108
+ testID="NotificationToastRenderer"
109
+ style={StyleSheet.absoluteFill}
110
+ pointerEvents="box-none"
111
+ >
112
+ {children}
113
+ <FlingGestureHandler
114
+ enabled={!!toast}
115
+ direction={Directions.UP}
116
+ onHandlerStateChange={onFlingStateChange}
117
+ >
118
+ <Animated.View
119
+ testID="NotificationToastRenderer-fling"
120
+ pointerEvents={toast ? "auto" : "none"}
121
+ style={animatedViewStyles}
122
+ >
123
+ <View style={{ height: statusHeight, backgroundColor }} />
124
+ <Text testID="NotificationToastRenderer-message" style={textStyles}>
125
+ {toast?.message}
126
+ </Text>
127
+ </Animated.View>
128
+ </FlingGestureHandler>
129
+ </View>
130
+ );
131
+ };
@@ -0,0 +1,61 @@
1
+ import * as React from "react";
2
+
3
+ import { notificationToastManager } from "./NotificationToastManager";
4
+ import { useNotificationToastState } from "./useNotificationToastState";
5
+
6
+ const styles: Record<string, React.CSSProperties> = {
7
+ body: {
8
+ position: "absolute",
9
+ bottom: 0,
10
+ left: 0,
11
+ right: 0,
12
+ width: 1920,
13
+ height: 200,
14
+ background:
15
+ "linear-gradient(180deg, rgba(17,17,17,0.9) 0%, rgba(17,17,17,1) 49%, rgba(0,0,0,0.85) 100%)",
16
+ display: "flex",
17
+ alignItems: "center",
18
+ justifyContent: "center",
19
+ flexDirection: "column",
20
+ color: "#fff",
21
+ paddingBottom: 15,
22
+ },
23
+ title: {
24
+ fontSize: 28,
25
+ margin: 0,
26
+ padding: 0,
27
+ },
28
+ subtitle: {
29
+ fontSize: 20,
30
+ margin: 0,
31
+ padding: 0,
32
+ },
33
+ };
34
+
35
+ export const NotificationToastRenderer = ({ children }) => {
36
+ const toast = useNotificationToastState();
37
+
38
+ return (
39
+ <div
40
+ data-testid="NotificationToastRenderer"
41
+ onClick={() => notificationToastManager.dismiss()}
42
+ >
43
+ {children}
44
+ {toast ? (
45
+ <div
46
+ data-testid="NotificationToastRenderer-touchable"
47
+ style={styles.body}
48
+ >
49
+ <div style={styles.title}>
50
+ <h2 data-testid="NotificationToastRenderer-message">
51
+ {toast.message}
52
+ </h2>
53
+ </div>
54
+ {toast.extraMessage ? (
55
+ <h4 style={styles.subtitle}>{toast.extraMessage}</h4>
56
+ ) : null}
57
+ </div>
58
+ ) : null}
59
+ </div>
60
+ );
61
+ };