rn-vs-lb 1.0.57 → 1.0.58
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/components/Modals/GuestAiChatModal.stories.tsx +202 -0
- package/components/Modals/GuestAiChatModal.tsx +249 -0
- package/components/Profile/ProfileCard/ProfileCard.tsx +3 -3
- package/components/UI/TabBar/TabBarAi.stories.tsx +106 -0
- package/components/UI/TabBar/TabBarAi.tsx +233 -0
- package/components/UI/ThreeDotsMenu.tsx +13 -7
- package/components/UI/UpdateRequiredView.stories.tsx +71 -0
- package/components/UI/UpdateRequiredView.tsx +65 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryFn } from '@storybook/react';
|
|
3
|
+
import { Button, FlatList, View } from 'react-native';
|
|
4
|
+
import GuestAiChatModalView, { type PureChatMessage } from './GuestAiChatModal';
|
|
5
|
+
import appTheme, { SIZES } from '../../theme/theme';
|
|
6
|
+
import { getTypography } from '../../theme/styles/styleSheet';
|
|
7
|
+
|
|
8
|
+
type GuestAiChatProps = React.ComponentProps<typeof GuestAiChatModalView>;
|
|
9
|
+
|
|
10
|
+
const theme = appTheme.lightTheme;
|
|
11
|
+
const typography = getTypography(theme);
|
|
12
|
+
|
|
13
|
+
const sampleMessages: PureChatMessage[] = [
|
|
14
|
+
{ id: '1', role: 'assistant', content: 'Привет! Я помогу найти событие мечты.' },
|
|
15
|
+
{ id: '2', role: 'user', content: 'Покажи вечеринки на выходных.' },
|
|
16
|
+
{
|
|
17
|
+
id: '3',
|
|
18
|
+
role: 'assistant',
|
|
19
|
+
content:
|
|
20
|
+
'Вот несколько вариантов: концерт в субботу, гастрономический фестиваль и выставка.\nВведите уточнение, чтобы подобрать лучше.',
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'Modals/GuestAiChatModal',
|
|
26
|
+
component: GuestAiChatModalView,
|
|
27
|
+
argTypes: {
|
|
28
|
+
onClose: { action: 'close modal' },
|
|
29
|
+
onSend: { action: 'send message' },
|
|
30
|
+
onChangeInput: { action: 'change input' },
|
|
31
|
+
listRef: {
|
|
32
|
+
table: {
|
|
33
|
+
disable: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
theme: { table: { disable: true } },
|
|
37
|
+
typography: { table: { disable: true } },
|
|
38
|
+
sizes: { table: { disable: true } },
|
|
39
|
+
},
|
|
40
|
+
args: {
|
|
41
|
+
visible: true,
|
|
42
|
+
botName: 'AI гид',
|
|
43
|
+
messages: sampleMessages,
|
|
44
|
+
inputValue: '',
|
|
45
|
+
error: null,
|
|
46
|
+
isSending: false,
|
|
47
|
+
limit: 10,
|
|
48
|
+
remaining: 10,
|
|
49
|
+
theme,
|
|
50
|
+
typography,
|
|
51
|
+
sizes: SIZES,
|
|
52
|
+
},
|
|
53
|
+
decorators: [
|
|
54
|
+
(StoryComponent) => (
|
|
55
|
+
<View
|
|
56
|
+
style={{
|
|
57
|
+
flex: 1,
|
|
58
|
+
minHeight: 640,
|
|
59
|
+
justifyContent: 'flex-end',
|
|
60
|
+
backgroundColor: '#0b0b0b55',
|
|
61
|
+
paddingBottom: 24,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<StoryComponent />
|
|
65
|
+
</View>
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
parameters: {
|
|
69
|
+
layout: 'fullscreen',
|
|
70
|
+
docs: {
|
|
71
|
+
description: {
|
|
72
|
+
component:
|
|
73
|
+
'Guest chat modal that lets unauthorised users ask AI-powered questions before signing in.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
} satisfies Meta<typeof GuestAiChatModalView>;
|
|
78
|
+
|
|
79
|
+
export default meta;
|
|
80
|
+
|
|
81
|
+
const Template: StoryFn<GuestAiChatProps> = (args) => {
|
|
82
|
+
const { onClose, onSend, ...rest } = args;
|
|
83
|
+
|
|
84
|
+
const [isVisible, setIsVisible] = React.useState(rest.visible);
|
|
85
|
+
const [inputValue, setInputValue] = React.useState(rest.inputValue);
|
|
86
|
+
const [messages, setMessages] = React.useState<PureChatMessage[]>(rest.messages);
|
|
87
|
+
const [remaining, setRemaining] = React.useState(rest.remaining);
|
|
88
|
+
const [isSending, setIsSending] = React.useState(rest.isSending);
|
|
89
|
+
const listRef = React.useRef<FlatList<PureChatMessage>>(null);
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
setIsVisible(rest.visible);
|
|
93
|
+
}, [rest.visible]);
|
|
94
|
+
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
setInputValue(rest.inputValue);
|
|
97
|
+
}, [rest.inputValue]);
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
setMessages(rest.messages);
|
|
101
|
+
}, [rest.messages]);
|
|
102
|
+
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
setRemaining(rest.remaining);
|
|
105
|
+
}, [rest.remaining]);
|
|
106
|
+
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
setIsSending(rest.isSending);
|
|
109
|
+
}, [rest.isSending]);
|
|
110
|
+
|
|
111
|
+
const handleClose = React.useCallback(() => {
|
|
112
|
+
setIsVisible(false);
|
|
113
|
+
onClose();
|
|
114
|
+
}, [onClose]);
|
|
115
|
+
|
|
116
|
+
const handleSend = React.useCallback(() => {
|
|
117
|
+
const trimmed = inputValue.trim();
|
|
118
|
+
if (!trimmed) return;
|
|
119
|
+
|
|
120
|
+
setIsSending(true);
|
|
121
|
+
onSend();
|
|
122
|
+
|
|
123
|
+
const userMessage: PureChatMessage = {
|
|
124
|
+
id: `user-${Date.now()}`,
|
|
125
|
+
role: 'user',
|
|
126
|
+
content: trimmed,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
130
|
+
setInputValue('');
|
|
131
|
+
setRemaining((prev) => (prev === undefined ? prev : Math.max(prev - 1, 0)));
|
|
132
|
+
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
setMessages((prev) => [
|
|
135
|
+
...prev,
|
|
136
|
+
{
|
|
137
|
+
id: `assistant-${Date.now()}`,
|
|
138
|
+
role: 'assistant',
|
|
139
|
+
content: `Я сохранил ваш запрос: "${trimmed}". Скоро пришлю подборку!`,
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
listRef.current?.scrollToEnd({ animated: true });
|
|
143
|
+
setIsSending(false);
|
|
144
|
+
}, 600);
|
|
145
|
+
}, [inputValue, onSend]);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<View style={{ flex: 1 }}>
|
|
149
|
+
<View style={{ marginBottom: 12, alignItems: 'center' }}>
|
|
150
|
+
<Button
|
|
151
|
+
title={isVisible ? 'Скрыть модалку' : 'Показать модалку'}
|
|
152
|
+
onPress={() => setIsVisible((prev) => !prev)}
|
|
153
|
+
/>
|
|
154
|
+
</View>
|
|
155
|
+
<GuestAiChatModalView
|
|
156
|
+
{...rest}
|
|
157
|
+
visible={isVisible}
|
|
158
|
+
messages={messages}
|
|
159
|
+
inputValue={inputValue}
|
|
160
|
+
remaining={remaining}
|
|
161
|
+
isSending={isSending}
|
|
162
|
+
onClose={handleClose}
|
|
163
|
+
onSend={handleSend}
|
|
164
|
+
onChangeInput={setInputValue}
|
|
165
|
+
listRef={listRef}
|
|
166
|
+
/>
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const Playground = Template.bind({});
|
|
172
|
+
Playground.args = {
|
|
173
|
+
visible: false,
|
|
174
|
+
remaining: 5,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const DefaultOpen: StoryFn<GuestAiChatProps> = (args) => Template({ ...args, visible: true });
|
|
178
|
+
DefaultOpen.args = {
|
|
179
|
+
visible: true,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const WithErrorState: StoryFn<GuestAiChatProps> = (args) => (
|
|
183
|
+
<GuestAiChatModalView
|
|
184
|
+
{...args}
|
|
185
|
+
visible
|
|
186
|
+
error="Не удалось подключиться к боту, попробуйте снова."
|
|
187
|
+
messages={args.messages}
|
|
188
|
+
inputValue={args.inputValue}
|
|
189
|
+
onChangeInput={args.onChangeInput ?? (() => undefined)}
|
|
190
|
+
onSend={args.onSend ?? (() => undefined)}
|
|
191
|
+
listRef={React.createRef()}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
WithErrorState.args = {
|
|
195
|
+
inputValue: 'Сообщение',
|
|
196
|
+
isSending: false,
|
|
197
|
+
};
|
|
198
|
+
WithErrorState.parameters = {
|
|
199
|
+
controls: {
|
|
200
|
+
exclude: ['listRef', 'onChangeInput', 'onSend'],
|
|
201
|
+
},
|
|
202
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import React, { FC, memo, RefObject, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
FlatList,
|
|
5
|
+
KeyboardAvoidingView,
|
|
6
|
+
Modal,
|
|
7
|
+
Platform,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
TextInput,
|
|
11
|
+
TouchableOpacity,
|
|
12
|
+
View,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
15
|
+
import { ThemeType, SizesType, TypographytType } from '../../theme';
|
|
16
|
+
|
|
17
|
+
export type PureChatMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
role: 'user' | 'assistant';
|
|
20
|
+
content: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type GuestAiChatViewProps = {
|
|
24
|
+
visible: boolean;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
botName?: string;
|
|
27
|
+
|
|
28
|
+
// данные
|
|
29
|
+
messages: PureChatMessage[];
|
|
30
|
+
inputValue: string;
|
|
31
|
+
error: string | null;
|
|
32
|
+
isSending: boolean;
|
|
33
|
+
limit?: number;
|
|
34
|
+
remaining?: number;
|
|
35
|
+
|
|
36
|
+
// экшены
|
|
37
|
+
onChangeInput: (v: string) => void;
|
|
38
|
+
onSend: () => void;
|
|
39
|
+
|
|
40
|
+
// ссылки
|
|
41
|
+
listRef: RefObject<FlatList<PureChatMessage>>;
|
|
42
|
+
|
|
43
|
+
// тема
|
|
44
|
+
theme: ThemeType;
|
|
45
|
+
typography: TypographytType;
|
|
46
|
+
sizes: SizesType;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const GuestAiChatModalView: FC<GuestAiChatViewProps> = memo(
|
|
50
|
+
({
|
|
51
|
+
visible,
|
|
52
|
+
onClose,
|
|
53
|
+
botName,
|
|
54
|
+
messages,
|
|
55
|
+
inputValue,
|
|
56
|
+
error,
|
|
57
|
+
isSending,
|
|
58
|
+
limit,
|
|
59
|
+
remaining,
|
|
60
|
+
onChangeInput,
|
|
61
|
+
onSend,
|
|
62
|
+
listRef,
|
|
63
|
+
theme,
|
|
64
|
+
typography,
|
|
65
|
+
sizes,
|
|
66
|
+
}) => {
|
|
67
|
+
const isAndroid = Platform.OS === 'android';
|
|
68
|
+
const styles = useMemo(() => getStyles(theme, isAndroid), [theme, isAndroid]);
|
|
69
|
+
|
|
70
|
+
const disabled =
|
|
71
|
+
isSending || !inputValue.trim() || (remaining !== undefined && remaining <= 0);
|
|
72
|
+
|
|
73
|
+
const renderItem = ({ item }: { item: PureChatMessage }) => {
|
|
74
|
+
const isUser = item.role === 'user';
|
|
75
|
+
return (
|
|
76
|
+
<View style={[styles.messageRow, isUser ? styles.rowEnd : styles.rowStart]}>
|
|
77
|
+
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.botBubble]}>
|
|
78
|
+
<Text style={[typography.body, isUser ? styles.userText : styles.botText]}>
|
|
79
|
+
{item.content}
|
|
80
|
+
</Text>
|
|
81
|
+
</View>
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Modal visible={visible} animationType="slide" onRequestClose={onClose} transparent>
|
|
88
|
+
<KeyboardAvoidingView
|
|
89
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
90
|
+
style={styles.centered}
|
|
91
|
+
>
|
|
92
|
+
<View style={styles.modalContainer}>
|
|
93
|
+
<View style={styles.header}>
|
|
94
|
+
<View>
|
|
95
|
+
<Text style={typography.titleH6}>{botName || 'AI-бот'}</Text>
|
|
96
|
+
{limit !== undefined && remaining !== undefined && (
|
|
97
|
+
<Text style={styles.limitText}>
|
|
98
|
+
Осталось сообщений: {remaining} / {limit}
|
|
99
|
+
</Text>
|
|
100
|
+
)}
|
|
101
|
+
</View>
|
|
102
|
+
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
|
103
|
+
<Ionicons name="close" size={sizes.md} color={theme.text} />
|
|
104
|
+
</TouchableOpacity>
|
|
105
|
+
</View>
|
|
106
|
+
|
|
107
|
+
<FlatList
|
|
108
|
+
ref={listRef}
|
|
109
|
+
data={messages}
|
|
110
|
+
keyExtractor={(item) => item.id}
|
|
111
|
+
renderItem={renderItem}
|
|
112
|
+
contentContainerStyle={styles.messagesContainer}
|
|
113
|
+
showsVerticalScrollIndicator={false}
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
|
117
|
+
|
|
118
|
+
<View style={styles.inputRow}>
|
|
119
|
+
<TextInput
|
|
120
|
+
style={styles.input}
|
|
121
|
+
placeholder="Спросите что-нибудь..."
|
|
122
|
+
placeholderTextColor={theme.greyText}
|
|
123
|
+
value={inputValue}
|
|
124
|
+
onChangeText={onChangeInput}
|
|
125
|
+
editable={!isSending && (remaining === undefined || remaining > 0)}
|
|
126
|
+
multiline
|
|
127
|
+
/>
|
|
128
|
+
<TouchableOpacity
|
|
129
|
+
onPress={onSend}
|
|
130
|
+
style={[styles.sendButton, disabled && styles.sendButtonDisabled]}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
>
|
|
133
|
+
{isSending ? (
|
|
134
|
+
<ActivityIndicator color={theme.white} />
|
|
135
|
+
) : (
|
|
136
|
+
<Ionicons name="send" size={18} color={theme.white} />
|
|
137
|
+
)}
|
|
138
|
+
</TouchableOpacity>
|
|
139
|
+
</View>
|
|
140
|
+
</View>
|
|
141
|
+
</KeyboardAvoidingView>
|
|
142
|
+
</Modal>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const getStyles = (theme: ThemeType, isAndroid: boolean) =>
|
|
148
|
+
StyleSheet.create({
|
|
149
|
+
centered: {
|
|
150
|
+
flex: 1,
|
|
151
|
+
backgroundColor: theme.backgroundSemiTransparent,
|
|
152
|
+
justifyContent: 'flex-end',
|
|
153
|
+
},
|
|
154
|
+
modalContainer: {
|
|
155
|
+
backgroundColor: theme.white,
|
|
156
|
+
borderTopLeftRadius: 16,
|
|
157
|
+
borderTopRightRadius: 16,
|
|
158
|
+
paddingHorizontal: 16,
|
|
159
|
+
paddingTop: 16,
|
|
160
|
+
paddingBottom: 24 + (isAndroid ? 25 : 0),
|
|
161
|
+
maxHeight: '85%',
|
|
162
|
+
},
|
|
163
|
+
header: {
|
|
164
|
+
flexDirection: 'row',
|
|
165
|
+
alignItems: 'center',
|
|
166
|
+
justifyContent: 'space-between',
|
|
167
|
+
marginBottom: 12,
|
|
168
|
+
},
|
|
169
|
+
closeButton: {
|
|
170
|
+
width: 32,
|
|
171
|
+
height: 32,
|
|
172
|
+
borderRadius: 16,
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: 'center',
|
|
175
|
+
backgroundColor: theme.background,
|
|
176
|
+
},
|
|
177
|
+
limitText: {
|
|
178
|
+
marginTop: 4,
|
|
179
|
+
fontSize: 12,
|
|
180
|
+
color: theme.greyText,
|
|
181
|
+
},
|
|
182
|
+
messagesContainer: {
|
|
183
|
+
paddingBottom: 12,
|
|
184
|
+
},
|
|
185
|
+
messageRow: {
|
|
186
|
+
flexDirection: 'row',
|
|
187
|
+
marginBottom: 8,
|
|
188
|
+
},
|
|
189
|
+
rowEnd: {
|
|
190
|
+
justifyContent: 'flex-end',
|
|
191
|
+
},
|
|
192
|
+
rowStart: {
|
|
193
|
+
justifyContent: 'flex-start',
|
|
194
|
+
},
|
|
195
|
+
messageBubble: {
|
|
196
|
+
maxWidth: '85%',
|
|
197
|
+
paddingHorizontal: 12,
|
|
198
|
+
paddingVertical: 8,
|
|
199
|
+
borderRadius: 12,
|
|
200
|
+
},
|
|
201
|
+
userBubble: {
|
|
202
|
+
backgroundColor: theme.primary,
|
|
203
|
+
borderBottomRightRadius: 2,
|
|
204
|
+
},
|
|
205
|
+
botBubble: {
|
|
206
|
+
backgroundColor: theme.background,
|
|
207
|
+
borderBottomLeftRadius: 2,
|
|
208
|
+
},
|
|
209
|
+
userText: {
|
|
210
|
+
color: theme.white,
|
|
211
|
+
},
|
|
212
|
+
botText: {
|
|
213
|
+
color: theme.text,
|
|
214
|
+
},
|
|
215
|
+
inputRow: {
|
|
216
|
+
flexDirection: 'row',
|
|
217
|
+
alignItems: 'flex-end',
|
|
218
|
+
marginTop: 8,
|
|
219
|
+
},
|
|
220
|
+
input: {
|
|
221
|
+
flex: 1,
|
|
222
|
+
minHeight: 40,
|
|
223
|
+
maxHeight: 120,
|
|
224
|
+
paddingHorizontal: 12,
|
|
225
|
+
paddingVertical: 10,
|
|
226
|
+
borderRadius: 12,
|
|
227
|
+
backgroundColor: theme.background,
|
|
228
|
+
color: theme.text,
|
|
229
|
+
},
|
|
230
|
+
sendButton: {
|
|
231
|
+
width: 42,
|
|
232
|
+
height: 42,
|
|
233
|
+
borderRadius: 21,
|
|
234
|
+
alignItems: 'center',
|
|
235
|
+
justifyContent: 'center',
|
|
236
|
+
backgroundColor: theme.primary,
|
|
237
|
+
marginLeft: 8,
|
|
238
|
+
},
|
|
239
|
+
sendButtonDisabled: {
|
|
240
|
+
opacity: 0.5,
|
|
241
|
+
},
|
|
242
|
+
errorText: {
|
|
243
|
+
color: theme.danger || '#E53935',
|
|
244
|
+
marginBottom: 4,
|
|
245
|
+
fontSize: 12,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export default GuestAiChatModalView;
|
|
@@ -155,10 +155,10 @@ export default function PureProfileCard(props: PureProfileCardProps) {
|
|
|
155
155
|
)}
|
|
156
156
|
|
|
157
157
|
{/* learn more */}
|
|
158
|
-
<TouchableOpacity onPress={onLearnMorePress} style={globalStyleSheet.flexRowCenter}>
|
|
158
|
+
{onLearnMorePress && <TouchableOpacity onPress={onLearnMorePress} style={globalStyleSheet.flexRowCenter}>
|
|
159
159
|
<MaterialIcons color={theme.placeholder} name="info-outline" size={18} />
|
|
160
160
|
<Text style={[typography.body, styles.learnMoreText]}>Learn more</Text>
|
|
161
|
-
</TouchableOpacity>
|
|
161
|
+
</TouchableOpacity>}
|
|
162
162
|
|
|
163
163
|
{/* CTA */}
|
|
164
164
|
{isAuth && !isMe && (
|
|
@@ -177,7 +177,7 @@ export default function PureProfileCard(props: PureProfileCardProps) {
|
|
|
177
177
|
</View>
|
|
178
178
|
)}
|
|
179
179
|
|
|
180
|
-
{isMe && (
|
|
180
|
+
{(isMe && onOpenCreatePoll || isMe && onOpenCreateEvent) &&(
|
|
181
181
|
<View style={styles.buttonsRow}>
|
|
182
182
|
{onOpenCreatePoll && (
|
|
183
183
|
<TouchableOpacity style={styles.btnOutline} onPress={onOpenCreatePoll}>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import TabBarAi, { type TabItem } from './TabBarAi';
|
|
5
|
+
|
|
6
|
+
type TabBarAiProps = React.ComponentProps<typeof TabBarAi>;
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TABS: TabItem[] = [
|
|
9
|
+
{ key: 'discover', label: 'Discover' },
|
|
10
|
+
{ key: 'specialists', label: 'Specialists' },
|
|
11
|
+
{ key: 'places', label: 'Places' },
|
|
12
|
+
{ key: 'events', label: 'Events' },
|
|
13
|
+
{ key: 'polls', label: 'Polls', disabled: true },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'UI/TabBar/TabBarAi',
|
|
18
|
+
component: TabBarAi,
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<View style={{ paddingVertical: 8, backgroundColor: '#1b1b1b' }}>
|
|
22
|
+
<Story />
|
|
23
|
+
</View>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
parameters: {
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
'Horizontal scrollable tab bar with animated indicator tailored for the AI discovery screens.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
argTypes: {
|
|
35
|
+
onChange: { action: 'onChange' },
|
|
36
|
+
activeIndex: {
|
|
37
|
+
control: { type: 'number' },
|
|
38
|
+
description: 'Index of the active tab. In the stories it is managed internally.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
args: {
|
|
42
|
+
tabs: DEFAULT_TABS,
|
|
43
|
+
activeIndex: 0,
|
|
44
|
+
activeColor: '#ffffff',
|
|
45
|
+
inactiveColor: 'rgba(255,255,255,0.55)',
|
|
46
|
+
indicatorColor: '#ffffff',
|
|
47
|
+
indicatorHeight: 4,
|
|
48
|
+
fontSize: 18,
|
|
49
|
+
},
|
|
50
|
+
} satisfies Meta<typeof TabBarAi>;
|
|
51
|
+
|
|
52
|
+
export default meta;
|
|
53
|
+
|
|
54
|
+
type Story = StoryObj<typeof TabBarAi>;
|
|
55
|
+
|
|
56
|
+
const Controlled: React.FC<TabBarAiProps> = ({ activeIndex = 0, onChange, ...rest }) => {
|
|
57
|
+
const [active, setActive] = useState(activeIndex);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setActive(activeIndex);
|
|
61
|
+
}, [activeIndex]);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<TabBarAi
|
|
65
|
+
{...rest}
|
|
66
|
+
activeIndex={active}
|
|
67
|
+
onChange={(index) => {
|
|
68
|
+
setActive(index);
|
|
69
|
+
onChange?.(index);
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const Default: Story = {
|
|
76
|
+
render: (args) => <Controlled {...args} />,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const DarkBackground: Story = {
|
|
80
|
+
render: (args) => <Controlled {...args} />,
|
|
81
|
+
args: {
|
|
82
|
+
activeColor: '#ffe066',
|
|
83
|
+
indicatorColor: '#ffe066',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const WithManyTabs: Story = {
|
|
88
|
+
render: (args) => <Controlled {...args} />,
|
|
89
|
+
args: {
|
|
90
|
+
tabs: Array.from({ length: 10 }).map((_, index) => ({
|
|
91
|
+
key: `tab-${index}`,
|
|
92
|
+
label: `Section ${index + 1}`,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const CustomSpacing: Story = {
|
|
98
|
+
render: (args) => <Controlled {...args} />,
|
|
99
|
+
args: {
|
|
100
|
+
gap: 12,
|
|
101
|
+
tabHorizontalPadding: 12,
|
|
102
|
+
fontSize: 16,
|
|
103
|
+
fontWeightActive: '600',
|
|
104
|
+
fontWeightInactive: '400',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// TabBar.tsx
|
|
2
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Animated,
|
|
5
|
+
LayoutChangeEvent,
|
|
6
|
+
Pressable,
|
|
7
|
+
StyleProp,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
View,
|
|
11
|
+
ViewStyle,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import type { ScrollView } from 'react-native';
|
|
14
|
+
|
|
15
|
+
export type TabItem = {
|
|
16
|
+
key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MeasuredTab = {
|
|
22
|
+
x: number; // абсолютный x внутри контента ScrollView (с учётом padding)
|
|
23
|
+
width: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type TabBarProps = {
|
|
27
|
+
tabs: TabItem[];
|
|
28
|
+
activeIndex: number;
|
|
29
|
+
onChange: (index: number) => void;
|
|
30
|
+
|
|
31
|
+
style?: StyleProp<ViewStyle>;
|
|
32
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
33
|
+
|
|
34
|
+
// стилизация
|
|
35
|
+
activeColor?: string;
|
|
36
|
+
inactiveColor?: string;
|
|
37
|
+
indicatorColor?: string;
|
|
38
|
+
indicatorHeight?: number;
|
|
39
|
+
fontSize?: number;
|
|
40
|
+
fontWeightActive?: '700' | '600' | '500' | '400';
|
|
41
|
+
fontWeightInactive?: '600' | '500' | '400' | '300';
|
|
42
|
+
tabHorizontalPadding?: number;
|
|
43
|
+
gap?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const PADDING_X = 12; // должен совпадать со styles.row.paddingHorizontal
|
|
47
|
+
|
|
48
|
+
export const TabBar: React.FC<TabBarProps> = memo((props) => {
|
|
49
|
+
const {
|
|
50
|
+
tabs,
|
|
51
|
+
activeIndex,
|
|
52
|
+
onChange,
|
|
53
|
+
style,
|
|
54
|
+
contentContainerStyle,
|
|
55
|
+
activeColor = '#000000',
|
|
56
|
+
inactiveColor = 'rgba(255,255,255,0.55)',
|
|
57
|
+
indicatorColor = '#000000',
|
|
58
|
+
indicatorHeight = 4,
|
|
59
|
+
fontSize = 24,
|
|
60
|
+
fontWeightActive = '700',
|
|
61
|
+
fontWeightInactive = '600',
|
|
62
|
+
tabHorizontalPadding = 4,
|
|
63
|
+
gap = 24,
|
|
64
|
+
} = props;
|
|
65
|
+
|
|
66
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
67
|
+
const [rootWidth, setRootWidth] = useState(0);
|
|
68
|
+
const scrollX = useRef(new Animated.Value(0)).current;
|
|
69
|
+
|
|
70
|
+
// измерения вкладок
|
|
71
|
+
const [measures, setMeasures] = useState<Record<string, MeasuredTab>>({});
|
|
72
|
+
const onTabLayout = useCallback(
|
|
73
|
+
(key: string) => (e: LayoutChangeEvent) => {
|
|
74
|
+
const { x, width } = e.nativeEvent.layout;
|
|
75
|
+
setMeasures((prev) =>
|
|
76
|
+
prev[key]?.x === x && prev[key]?.width === width ? prev : { ...prev, [key]: { x, width } },
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
[],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// индикатор: позиционируем по центру вкладки
|
|
83
|
+
const centerX = useRef(new Animated.Value(0)).current; // координата центра ОТНОСИТЕЛЬНО left=PADDING_X
|
|
84
|
+
const scaleX = useRef(new Animated.Value(1)).current; // ширина индикатора (масштаб по X)
|
|
85
|
+
|
|
86
|
+
const animateTo = useCallback(
|
|
87
|
+
(centerRel: number, width: number) => {
|
|
88
|
+
Animated.parallel([
|
|
89
|
+
Animated.spring(centerX, {
|
|
90
|
+
toValue: centerRel,
|
|
91
|
+
useNativeDriver: true,
|
|
92
|
+
bounciness: 6,
|
|
93
|
+
speed: 15,
|
|
94
|
+
}),
|
|
95
|
+
Animated.spring(scaleX, {
|
|
96
|
+
toValue: Math.max(1, width), // базовая ширина 1 -> растягиваем до нужной
|
|
97
|
+
useNativeDriver: true,
|
|
98
|
+
bounciness: 6,
|
|
99
|
+
speed: 15,
|
|
100
|
+
}),
|
|
101
|
+
]).start();
|
|
102
|
+
},
|
|
103
|
+
[centerX, scaleX],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// пересчёт при смене активной вкладки или измерений
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const tab = tabs[activeIndex];
|
|
109
|
+
if (!tab) return;
|
|
110
|
+
const m = measures[tab.key];
|
|
111
|
+
if (!m) return;
|
|
112
|
+
|
|
113
|
+
const targetWidth = Math.max(16, m.width * 0.6);
|
|
114
|
+
const centerAbs = m.x + m.width / 2; // абсолютный центр относительно контента
|
|
115
|
+
const centerRel = centerAbs - PADDING_X; // смещаем, т.к. индикатор имеет left=PADDING_X
|
|
116
|
+
|
|
117
|
+
animateTo(centerRel, targetWidth);
|
|
118
|
+
|
|
119
|
+
// автоцентрирование активной вкладки в видимой области
|
|
120
|
+
const desiredScrollX = Math.max(0, centerAbs - rootWidth / 2);
|
|
121
|
+
scrollRef.current?.scrollTo({ x: desiredScrollX, y: 0, animated: true });
|
|
122
|
+
}, [activeIndex, measures, tabs, rootWidth, animateTo]);
|
|
123
|
+
|
|
124
|
+
const handlePress = useCallback(
|
|
125
|
+
(index: number, disabled?: boolean) => {
|
|
126
|
+
if (!disabled) onChange(index);
|
|
127
|
+
},
|
|
128
|
+
[onChange],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const contentGapStyle = useMemo(() => ({ columnGap: gap }), [gap]);
|
|
132
|
+
|
|
133
|
+
const translateX = useMemo(() => Animated.subtract(centerX, scrollX), [centerX, scrollX]);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<View
|
|
137
|
+
style={[styles.root, style]}
|
|
138
|
+
onLayout={(e) => setRootWidth(e.nativeEvent.layout.width)}
|
|
139
|
+
>
|
|
140
|
+
<Animated.ScrollView
|
|
141
|
+
ref={scrollRef}
|
|
142
|
+
horizontal
|
|
143
|
+
showsHorizontalScrollIndicator={false}
|
|
144
|
+
contentContainerStyle={[styles.row, contentGapStyle, contentContainerStyle]}
|
|
145
|
+
keyboardShouldPersistTaps="handled"
|
|
146
|
+
// important: чтобы Pressable не обрезался тенями
|
|
147
|
+
overScrollMode="never"
|
|
148
|
+
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
|
|
149
|
+
useNativeDriver: true,
|
|
150
|
+
})}
|
|
151
|
+
scrollEventThrottle={16}
|
|
152
|
+
>
|
|
153
|
+
{tabs.map((t, i) => {
|
|
154
|
+
const isActive = i === activeIndex;
|
|
155
|
+
return (
|
|
156
|
+
<Pressable
|
|
157
|
+
key={t.key}
|
|
158
|
+
onPress={() => handlePress(i, t.disabled)}
|
|
159
|
+
onLayout={onTabLayout(t.key)}
|
|
160
|
+
android_ripple={{ color: 'rgba(255,255,255,0.08)', borderless: true }}
|
|
161
|
+
style={({ pressed }) => [
|
|
162
|
+
styles.tab,
|
|
163
|
+
{ paddingHorizontal: tabHorizontalPadding, opacity: t.disabled ? 0.5 : pressed ? 0.8 : 1 },
|
|
164
|
+
]}
|
|
165
|
+
accessibilityRole="tab"
|
|
166
|
+
accessibilityState={{ selected: isActive, disabled: !!t.disabled }}
|
|
167
|
+
>
|
|
168
|
+
<Text
|
|
169
|
+
numberOfLines={1}
|
|
170
|
+
style={[
|
|
171
|
+
styles.label,
|
|
172
|
+
{
|
|
173
|
+
color: isActive ? activeColor : inactiveColor,
|
|
174
|
+
fontSize,
|
|
175
|
+
fontWeight: isActive ? fontWeightActive : fontWeightInactive,
|
|
176
|
+
},
|
|
177
|
+
]}
|
|
178
|
+
>
|
|
179
|
+
{t.label}
|
|
180
|
+
</Text>
|
|
181
|
+
</Pressable>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</Animated.ScrollView>
|
|
185
|
+
|
|
186
|
+
{/* Индикатор: width анимируем через scaleX (native-driver) */}
|
|
187
|
+
<Animated.View
|
|
188
|
+
pointerEvents="none"
|
|
189
|
+
style={[
|
|
190
|
+
styles.indicator,
|
|
191
|
+
{
|
|
192
|
+
height: indicatorHeight,
|
|
193
|
+
backgroundColor: indicatorColor,
|
|
194
|
+
// базовая «единичка», далее растягиваем scaleX до нужной ширины
|
|
195
|
+
width: 1,
|
|
196
|
+
transform: [
|
|
197
|
+
{ translateX }, // сдвиг центра
|
|
198
|
+
{ scaleX }, // ширина
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
]}
|
|
202
|
+
/>
|
|
203
|
+
</View>
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const styles = StyleSheet.create({
|
|
208
|
+
root: {
|
|
209
|
+
position: 'relative',
|
|
210
|
+
backgroundColor: 'transparent',
|
|
211
|
+
},
|
|
212
|
+
row: {
|
|
213
|
+
flexDirection: 'row',
|
|
214
|
+
alignItems: 'flex-end',
|
|
215
|
+
paddingHorizontal: PADDING_X,
|
|
216
|
+
paddingTop: 4,
|
|
217
|
+
paddingBottom: 4,
|
|
218
|
+
},
|
|
219
|
+
tab: {
|
|
220
|
+
paddingVertical: 6,
|
|
221
|
+
},
|
|
222
|
+
label: {
|
|
223
|
+
letterSpacing: 0.2,
|
|
224
|
+
},
|
|
225
|
+
indicator: {
|
|
226
|
+
position: 'absolute',
|
|
227
|
+
left: PADDING_X, // точка отсчёта для centerX
|
|
228
|
+
bottom: 0,
|
|
229
|
+
borderRadius: 999,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export default TabBar;
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
StyleSheet,
|
|
7
7
|
Modal,
|
|
8
8
|
Pressable,
|
|
9
|
-
LayoutRectangle
|
|
9
|
+
LayoutRectangle,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
StyleProp
|
|
10
12
|
} from 'react-native';
|
|
11
13
|
import { Ionicons } from '@expo/vector-icons';
|
|
12
14
|
import { ThemeType, useTheme } from '../../theme';
|
|
13
15
|
import { IoniconsProps } from '../../types/Icon';
|
|
14
16
|
|
|
15
|
-
type MenuItem = {
|
|
17
|
+
export type MenuItem = {
|
|
16
18
|
label: string;
|
|
17
19
|
icon?: IoniconsProps;
|
|
18
20
|
colorIcon?: any;
|
|
@@ -21,9 +23,13 @@ type MenuItem = {
|
|
|
21
23
|
|
|
22
24
|
type Props = {
|
|
23
25
|
items: MenuItem[];
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
iconColor?: string;
|
|
28
|
+
positionLeft?: number;
|
|
29
|
+
positionTop?: number;
|
|
24
30
|
};
|
|
25
31
|
|
|
26
|
-
export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
|
|
32
|
+
export const ThreeDotsMenu: React.FC<Props> = ({ items, style, iconColor, positionLeft, positionTop }) => {
|
|
27
33
|
const { theme, typography, globalStyleSheet } = useTheme();
|
|
28
34
|
|
|
29
35
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
@@ -45,8 +51,8 @@ export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
|
|
|
45
51
|
|
|
46
52
|
return (
|
|
47
53
|
<View>
|
|
48
|
-
<TouchableOpacity ref={buttonRef as any} onPress={openMenu}>
|
|
49
|
-
<Ionicons name="ellipsis-vertical" size={22} color={theme.primary} />
|
|
54
|
+
<TouchableOpacity style={style} ref={buttonRef as any} onPress={openMenu}>
|
|
55
|
+
<Ionicons name="ellipsis-vertical" size={22} color={iconColor ?? theme.primary} />
|
|
50
56
|
</TouchableOpacity>
|
|
51
57
|
|
|
52
58
|
<Modal transparent visible={menuVisible} animationType="fade" onRequestClose={closeMenu}>
|
|
@@ -56,8 +62,8 @@ export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
|
|
|
56
62
|
style={[
|
|
57
63
|
styles.dropdown,
|
|
58
64
|
{
|
|
59
|
-
top: position.y + position.height,
|
|
60
|
-
left: position.x - 160 + position.width, // подправь под себя
|
|
65
|
+
top: position.y + position.height + (positionTop ?? 0),
|
|
66
|
+
left: position.x - (positionLeft ?? 160) + position.width, // подправь под себя
|
|
61
67
|
},
|
|
62
68
|
]}
|
|
63
69
|
>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from 'react';
|
|
2
|
+
import { Animated } from 'react-native';
|
|
3
|
+
import { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
import { UpdateRequiredView } from './UpdateRequiredView';
|
|
5
|
+
import { ThemeProvider } from '../../theme';
|
|
6
|
+
|
|
7
|
+
// 👇 Декоратор, чтобы обернуть компонент в тему
|
|
8
|
+
const withTheme = (StoryFn: any) => (
|
|
9
|
+
<ThemeProvider>
|
|
10
|
+
<StoryFn />
|
|
11
|
+
</ThemeProvider>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof UpdateRequiredView> = {
|
|
15
|
+
title: 'UI/UpdateRequiredView',
|
|
16
|
+
component: UpdateRequiredView,
|
|
17
|
+
decorators: [withTheme],
|
|
18
|
+
argTypes: {
|
|
19
|
+
onPressUpdate: { action: 'pressed update' },
|
|
20
|
+
onRefresh: { action: 'refreshed' },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof UpdateRequiredView>;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
render: (args) => {
|
|
30
|
+
const anim = useRef(new Animated.Value(0)).current;
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
Animated.loop(
|
|
34
|
+
Animated.sequence([
|
|
35
|
+
Animated.timing(anim, {
|
|
36
|
+
toValue: -10,
|
|
37
|
+
duration: 600,
|
|
38
|
+
useNativeDriver: true,
|
|
39
|
+
}),
|
|
40
|
+
Animated.timing(anim, {
|
|
41
|
+
toValue: 0,
|
|
42
|
+
duration: 600,
|
|
43
|
+
useNativeDriver: true,
|
|
44
|
+
}),
|
|
45
|
+
])
|
|
46
|
+
).start();
|
|
47
|
+
}, [anim]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<UpdateRequiredView
|
|
51
|
+
{...args}
|
|
52
|
+
anim={anim}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
args: {
|
|
57
|
+
refreshing: false,
|
|
58
|
+
title: 'Необходимо обновление',
|
|
59
|
+
description:
|
|
60
|
+
'Для корректной работы приложения установите последнюю версию. Нажмите кнопку ниже, чтобы перейти в магазин.',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Refreshing: Story = {
|
|
65
|
+
render: Default.render,
|
|
66
|
+
args: {
|
|
67
|
+
refreshing: true,
|
|
68
|
+
title: 'Проверка обновлений...',
|
|
69
|
+
description: 'Подождите, идет проверка доступных обновлений.',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
ScrollView,
|
|
7
|
+
RefreshControl,
|
|
8
|
+
Animated,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useTheme } from '../../theme';
|
|
11
|
+
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
12
|
+
import Spacer from './Spacer';
|
|
13
|
+
import { Button } from '../Button';
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
anim: Animated.Value; // внешний Animated.Value с уже запущенным loop
|
|
17
|
+
refreshing: boolean;
|
|
18
|
+
onRefresh: () => void;
|
|
19
|
+
onPressUpdate: () => void;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const UpdateRequiredView = memo(({ anim, refreshing, onRefresh, onPressUpdate, title, description }: Props) => {
|
|
25
|
+
const { theme, commonStyles, typography } = useTheme() as any;
|
|
26
|
+
|
|
27
|
+
const ui = {
|
|
28
|
+
bg: theme?.background ?? '#fff',
|
|
29
|
+
primary: theme?.primary ?? '#6f2da8',
|
|
30
|
+
container: commonStyles?.container ?? { flex: 1, padding: 16 },
|
|
31
|
+
titleH2: typography?.titleH2 ?? { fontSize: 24, fontWeight: '700' },
|
|
32
|
+
body: typography?.body ?? { fontSize: 16 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<ScrollView
|
|
37
|
+
contentContainerStyle={[ui.container, { backgroundColor: ui.bg, flexGrow: 1, justifyContent: 'center' }]}
|
|
38
|
+
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
|
39
|
+
>
|
|
40
|
+
<Animated.View style={[styles.iconContainer, { transform: [{ translateY: anim }] }]}>
|
|
41
|
+
<MaterialCommunityIcons name="update" size={64} color={ui.primary} />
|
|
42
|
+
</Animated.View>
|
|
43
|
+
|
|
44
|
+
<Spacer size="xl" />
|
|
45
|
+
<Text style={[ui.titleH2, styles.title]}>{title}</Text>
|
|
46
|
+
|
|
47
|
+
<Spacer size="sm" />
|
|
48
|
+
<Text style={[ui.body, styles.description]}>
|
|
49
|
+
{description}
|
|
50
|
+
</Text>
|
|
51
|
+
|
|
52
|
+
<Spacer size="xl" />
|
|
53
|
+
<View style={styles.button}>
|
|
54
|
+
<Button title="Обновить" onPress={onPressUpdate} />
|
|
55
|
+
</View>
|
|
56
|
+
</ScrollView>
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
button: { width: '70%' },
|
|
62
|
+
iconContainer: { alignItems: 'center' },
|
|
63
|
+
title: { textAlign: 'center' },
|
|
64
|
+
description: { textAlign: 'center', paddingHorizontal: 20 },
|
|
65
|
+
});
|