@ledvance/ui-biz-bundle 1.1.168 → 1.1.169

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.
@@ -0,0 +1,423 @@
1
+ import { useCallback, useMemo } from "react"
2
+ import { parseMinutes, PriceSegment, UnitList } from "./component/EnergyModal"
3
+ import { SetSegmentedPricesParams } from "./SetSegmentedPricesScreen"
4
+ import { useReactive } from "ahooks"
5
+ import { showDialog } from "@ledvance/base/src/utils/common";
6
+ import I18n from "@ledvance/base/src/i18n";
7
+ import { StyleSheet, FlatList, Image, TouchableOpacity, View, Text } from "react-native";
8
+ import { Utils, Popup } from "tuya-panel-kit";
9
+ import ThemeType from "@ledvance/base/src/config/themeType";
10
+ import res from "@ledvance/base/src/res";
11
+ import Card from "@ledvance/base/src/components/Card";
12
+ import Spacer from "@ledvance/base/src/components/Spacer";
13
+ import React from "react";
14
+
15
+ interface EditingPopupState {
16
+ isShow: boolean
17
+ editingSegmentPrice: PriceSegment | undefined
18
+ }
19
+ export interface SetSegmentedPricesUiState {
20
+ editingSegmentPrice: EditingPopupState,
21
+ segments: PriceSegment[],
22
+ priceUnit: string,
23
+ selectedSegmentprice?: PriceSegment,
24
+ isLoading: boolean,
25
+ isDataChanged: boolean,
26
+ }
27
+
28
+ export const useSetSegmentPrice = (params: SetSegmentedPricesParams, theme?: ThemeType) => {
29
+ const cx = Utils.RatioUtils.convertX
30
+ const initialSegments = useMemo(() => {
31
+ return (params.isGenerate ? params.dynamicData.generatePrices : params.dynamicData.intervalPrices) ?? []
32
+ }, [params]);
33
+
34
+ const uiState = useReactive<SetSegmentedPricesUiState>({
35
+ editingSegmentPrice: {
36
+ isShow: false,
37
+ editingSegmentPrice: undefined,
38
+ },
39
+ segments: initialSegments,
40
+ priceUnit: params.priceUnit,
41
+ selectedSegmentprice: undefined,
42
+ isLoading: false,
43
+ isDataChanged: false,
44
+ });
45
+ const showAddPopup = useCallback(() => {
46
+ uiState.editingSegmentPrice = {
47
+ isShow: true,
48
+ editingSegmentPrice: undefined,
49
+ }
50
+ }, [uiState]);
51
+ const showEditPopup = useCallback((item: PriceSegment) => {
52
+ uiState.editingSegmentPrice = {
53
+ isShow: true,
54
+ editingSegmentPrice: item,
55
+ };
56
+ }, [uiState]);
57
+ const hidePopup = useCallback(() => {
58
+ uiState.editingSegmentPrice = {
59
+ isShow: false,
60
+ editingSegmentPrice: undefined,
61
+ };
62
+ }, [uiState]);
63
+ const insertPriceSegment = useCallback((newInterval: PriceSegment) => {
64
+ const result: PriceSegment[] = [];
65
+ const intervals = uiState.segments;
66
+ for (const old of intervals) {
67
+ // 无交集
68
+ if (
69
+ old.endMinute <= newInterval.startMinute ||
70
+ old.startMinute >= newInterval.endMinute
71
+ ) {
72
+ result.push(old)
73
+ continue
74
+ }
75
+
76
+ // 保留左边未覆盖部分
77
+ if (old.startMinute < newInterval.startMinute) {
78
+ result.push({
79
+ startMinute: old.startMinute,
80
+ endMinute: newInterval.startMinute,
81
+ price: old.price,
82
+ })
83
+ }
84
+
85
+ // 保留右边未覆盖部分
86
+ if (old.endMinute > newInterval.endMinute) {
87
+ result.push({
88
+ startMinute: newInterval.endMinute,
89
+ endMinute: old.endMinute,
90
+ price: old.price,
91
+ })
92
+ }
93
+ }
94
+
95
+ // 插入新区间
96
+ result.push(newInterval)
97
+
98
+ // 排序
99
+ result.sort((a, b) => a.startMinute - b.startMinute)
100
+
101
+ // 合并相邻且价格相同区间
102
+ const merged: PriceSegment[] = []
103
+
104
+ for (const seg of result) {
105
+ const last = merged[merged.length - 1]
106
+
107
+ if (
108
+ last &&
109
+ last.price === seg.price &&
110
+ last.endMinute === seg.startMinute
111
+ ) {
112
+ last.endMinute = seg.endMinute
113
+ } else {
114
+ merged.push({ ...seg })
115
+ }
116
+ }
117
+ uiState.segments = merged
118
+ uiState.isDataChanged = true
119
+ }, [uiState])
120
+ const parsePriceSegmentText = useCallback((startMinutes: number, endMinutes: number, price: number, currency: string) => {
121
+ const { hour: sh, minute: sm } = parseMinutes(startMinutes)
122
+ const { hour: eh, minute: em } = parseMinutes(endMinutes)
123
+ return `${sh}:${sm} - ${eh}:${em} ${price}${currency}/kWh`;
124
+ }, [])
125
+ const showAlerDialog = useCallback((content: string) => {
126
+ showDialog({
127
+ method: 'alert',
128
+ title: I18n.getLang('title_tips'),
129
+ subTitle: content,
130
+ onConfirm: async (_, { close }) => {
131
+ close()
132
+ }
133
+ })
134
+ }, [])
135
+
136
+ const verifyEditingPriceSegment = useCallback((item: PriceSegment | undefined) => {
137
+ if (!item) {
138
+ showAlerDialog(I18n.getLang('error_end_timer_must_greater'))
139
+ return false
140
+ }
141
+ const { startMinute, endMinute, price } = item
142
+ if (price == undefined || price == 0) {
143
+ //toast price must be greater than 0
144
+ // uiState.toastMessage = "price must be greater than 0"
145
+ showAlerDialog(I18n.getLang('error_price_must_greater_than_z'))
146
+ return false
147
+ }
148
+ if (endMinute <= startMinute) {
149
+ //toast end timer must be greater than the start timer
150
+ // uiState.toastMessage = "end timer must be greater than the start timer"
151
+ showAlerDialog(I18n.getLang('error_end_timer_must_greater'))
152
+ return false
153
+ }
154
+ return true
155
+ }, [])
156
+ const checkFullDayCoverage = (segments: PriceSegment[]): boolean => {
157
+ if (!segments || segments.length === 0) {
158
+ return false;
159
+ }
160
+
161
+ const sorted = [...segments].sort((a, b) => a.startMinute - b.startMinute);
162
+
163
+ if (sorted[0].startMinute !== 0) {
164
+ return false;
165
+ }
166
+
167
+ let currentEnd = sorted[0].endMinute;
168
+
169
+ for (let i = 1; i < sorted.length; i++) {
170
+ const nextSegment = sorted[i];
171
+
172
+ if (nextSegment.startMinute !== currentEnd) {
173
+ return false;
174
+ }
175
+
176
+ currentEnd = nextSegment.endMinute;
177
+ }
178
+
179
+ return currentEnd >= 1439;
180
+ }
181
+ const verifySegmentsResult = useCallback(() => {
182
+ const segments = uiState.segments
183
+ if (!checkFullDayCoverage(segments)) {
184
+ showAlerDialog(I18n.getLang('error_time_period_not_full_day'))
185
+ return false
186
+ }
187
+ if (segments.length < 2) {
188
+ showAlerDialog(I18n.getLang('error_time_period_single'))
189
+ return false
190
+ }
191
+ return true
192
+ }, [])
193
+ const handleSaveClick = async (onSuccess: () => void) => {
194
+ if (!verifySegmentsResult()) {
195
+ return
196
+ }
197
+ uiState.isLoading = true
198
+ const isSuccess = await params.onSetPriceSements(uiState.segments, uiState.priceUnit)
199
+ uiState.isLoading = false
200
+ if (isSuccess) {
201
+ onSuccess()
202
+ } else {
203
+ showAlerDialog(I18n.getLang('common_server_api_error'))
204
+ }
205
+ }
206
+ const colorMapRef = React.useRef<Map<number, string>>(new Map())
207
+ const colors = [
208
+ '#00D084', // green
209
+ '#4A90E2', // blue
210
+ '#F5A623', // orange
211
+ '#BD10E0', // purple
212
+ '#7ED321', // light green
213
+ '#F8E71C', // yellow
214
+ '#FF6B6B', // red
215
+ '#50E3C2', // teal
216
+ ]
217
+
218
+ useMemo(() => {
219
+ initialSegments.forEach((seg, index) => {
220
+ if (!colorMapRef.current.has(seg.startMinute)) {
221
+ colorMapRef.current.set(seg.startMinute, colors[index % colors.length]);
222
+ }
223
+ });
224
+ }, [initialSegments]);
225
+
226
+ //取颜色 使用map 确保颜色的一致性与连续性
227
+ const getSegmentColor = useCallback((startMinute: number, _price: number): string => {
228
+ let color = colorMapRef.current.get(startMinute);
229
+ if (!color) {
230
+ //找出当前未被使用的颜色,保证颜色列表充分利用且不重复
231
+ const usedColors = new Set(
232
+ uiState.segments
233
+ .map(seg => colorMapRef.current.get(seg.startMinute))
234
+ .filter(Boolean)
235
+ );
236
+ color = colors.find(c => !usedColors.has(c)) || colors[colorMapRef.current.size % colors.length];
237
+ colorMapRef.current.set(startMinute, color);
238
+ }
239
+ return color;
240
+ }, [uiState.segments]);
241
+
242
+ const showCurrencyPopup = () => {
243
+ const { height, statusBarHeight } = Utils.RatioUtils
244
+ Popup.custom({
245
+ title: (
246
+ <View style={{ backgroundColor: theme?.card.head, flexDirection: 'row', height: cx(60), justifyContent: 'space-between', alignItems: 'center', borderTopLeftRadius: cx(10), borderTopRightRadius: cx(10), paddingHorizontal: cx(8) }}>
247
+ <TouchableOpacity onPress={() => Popup.close()}>
248
+ <Text style={{ color: theme?.global.secondBrand, fontSize: cx(16) }}>{I18n.getLang('auto_scan_system_cancel')}</Text>
249
+ </TouchableOpacity>
250
+ </View>
251
+ ),
252
+ wrapperStyle: {
253
+ height: height - statusBarHeight - cx(40),
254
+ backgroundColor: theme?.global.background
255
+ },
256
+ footer: (<View style={{ backgroundColor: theme?.global.background }}></View>),
257
+ onMaskPress: () => { },
258
+ motionType: 'none',
259
+ useKeyboardView: true,
260
+ content: (
261
+ <View
262
+ style={{ backgroundColor: theme?.global.background, paddingHorizontal: cx(16) }}>
263
+ <Spacer />
264
+ <Card>
265
+ <FlatList
266
+ data={UnitList}
267
+ initialNumToRender={15}
268
+ renderItem={({ item }) => (
269
+ <TouchableOpacity
270
+ onPress={() => {
271
+ uiState.priceUnit = item
272
+ uiState.isDataChanged = true
273
+ Popup.close()
274
+ }}
275
+ >
276
+ <View style={styles.unitItem}>
277
+ <Text style={{ fontSize: cx(16), color: theme?.global.fontColor }}>{item}</Text>
278
+ {uiState.priceUnit === item && <Image
279
+ style={{ width: cx(16), height: cx(16) }}
280
+ source={{ uri: res.app_music_check }}
281
+ resizeMode="contain"
282
+ />}
283
+ </View>
284
+ </TouchableOpacity>
285
+ )}
286
+ ItemSeparatorComponent={() => (
287
+ <View style={{ flex: 1, height: 1, backgroundColor: theme?.card.background }}></View>
288
+ )}
289
+ keyExtractor={item => item}
290
+ />
291
+ </Card>
292
+ <Spacer />
293
+ </View>
294
+ )
295
+ })
296
+ }
297
+
298
+ const deletePriceSegment = useCallback((itemToDelete: PriceSegment) => {
299
+ showDialog({
300
+ method: 'confirm',
301
+ title: I18n.getLang('title_tips'),
302
+ subTitle: I18n.getLang('consumption_confirm_delete_period'),
303
+ onConfirm: (_, { close }) => {
304
+ close()
305
+ uiState.isDataChanged = true
306
+ uiState.segments = uiState.segments.filter(item =>
307
+ !(item.startMinute === itemToDelete.startMinute && item.endMinute === itemToDelete.endMinute)
308
+ )
309
+ if (uiState.selectedSegmentprice?.startMinute === itemToDelete.startMinute &&
310
+ uiState.selectedSegmentprice?.endMinute === itemToDelete.endMinute) {
311
+ uiState.selectedSegmentprice = undefined;
312
+ }
313
+ }
314
+ })
315
+ }, [uiState])
316
+
317
+ const styles = StyleSheet.create(
318
+ {
319
+ cardStyle: {
320
+ paddingHorizontal: cx(16),
321
+ paddingVertical: cx(8),
322
+ elevation: 1
323
+ },
324
+ addPeriodCircle: {
325
+ justifyContent: 'center',
326
+ alignItems: 'center',
327
+ width: cx(16),
328
+ height: cx(16),
329
+ borderRadius: cx(8),
330
+ backgroundColor: theme?.global.brand
331
+ },
332
+ addPeriodImg: {
333
+ width: cx(14),
334
+ height: cx(14),
335
+ tintColor: theme?.container.background
336
+ },
337
+ scrollContainer: {
338
+ paddingHorizontal: cx(16),
339
+ },
340
+ currencyCardContainer: {
341
+ flexDirection: 'row',
342
+ paddingHorizontal: cx(16),
343
+ paddingVertical: cx(8),
344
+ alignItems: 'center',
345
+ justifyContent: 'space-between',
346
+ },
347
+ cardTitle: {
348
+ fontSize: cx(16),
349
+ fontWeight: 'bold',
350
+ color: theme?.global.fontColor,
351
+ },
352
+ line: {
353
+ height: 1,
354
+ position: 'absolute',
355
+ start: cx(4),
356
+ end: cx(4),
357
+ bottom: 0,
358
+ backgroundColor: theme?.textInput.line,
359
+ },
360
+ textGroup: {
361
+ flexDirection: 'row',
362
+ borderRadius: cx(4),
363
+ backgroundColor: theme?.textInput.background,
364
+ alignItems: 'center',
365
+ flex: 1,
366
+ },
367
+ textParent: {
368
+ flex: 1,
369
+ height: cx(44),
370
+ justifyContent: 'center',
371
+ },
372
+ text: {
373
+ marginStart: cx(16),
374
+ marginEnd: cx(6),
375
+ fontSize: cx(16),
376
+ color: theme?.textInput.fontColor,
377
+ fontFamily: 'helvetica_neue_lt_std_roman',
378
+ },
379
+ unitItem: {
380
+ flexDirection: 'row',
381
+ justifyContent: 'space-between',
382
+ paddingHorizontal: cx(10),
383
+ alignItems: 'center',
384
+ height: cx(40),
385
+ },
386
+ verticalIndicator: {
387
+ position: 'absolute',
388
+ top: cx(8),
389
+ bottom: 0,
390
+ left: 0,
391
+ width: cx(40),
392
+ },
393
+ segmentItem: {
394
+ position: 'relative',
395
+ marginTop: cx(8),
396
+ marginLeft: cx(24),
397
+ marginRight: cx(24),
398
+ },
399
+ editSegmentIndicator: {
400
+ width: cx(18),
401
+ height: cx(18),
402
+ marginHorizontal: cx(6),
403
+ },
404
+ deleteSegmentIndicator: {
405
+ position: 'absolute',
406
+ top: -cx(6),
407
+ right: -cx(6),
408
+ width: cx(14),
409
+ height: cx(14),
410
+ zIndex: 99,
411
+ elevation: 5,
412
+ backgroundColor: '#ffffff',
413
+ borderRadius: cx(12),
414
+ },
415
+ }
416
+ )
417
+
418
+ return {
419
+ uiState, showAddPopup, showEditPopup, hidePopup, insertPriceSegment, parsePriceSegmentText,
420
+ verifyEditingPriceSegment, handleSaveClick, getSegmentColor, styles, showCurrencyPopup, deletePriceSegment
421
+ }
422
+
423
+ }
@@ -0,0 +1,189 @@
1
+ import React, {} from "react";
2
+ import {Image, ScrollView, StyleSheet, Text, TouchableOpacity, View} from "react-native";
3
+ import {Utils} from 'tuya-panel-kit'
4
+ import Card from "@ledvance/base/src/components/Card";
5
+ import ThemeType from "@ledvance/base/src/config/themeType";
6
+ import res from "@ledvance/base/src/res";
7
+ import Spacer from "@ledvance/base/src/components/Spacer";
8
+ import Page from "@ledvance/base/src/components/Page";
9
+ import I18n from "@ledvance/base/src/i18n";
10
+ import {useRoute} from '@react-navigation/core';
11
+ import EnergyPopup, {DynamicEnergyData, PriceSegment} from './component/EnergyModal';
12
+ import {useSetSegmentPrice} from "./SetSegmentPricesContract";
13
+ import VerticalDailyPricesIndicator from "./component/VerticalDailyPricesIndicator";
14
+ import {useNavigation} from '@react-navigation/core'
15
+
16
+ const {withTheme} = Utils.ThemeUtils
17
+ const {convertX: cx} = Utils.RatioUtils
18
+
19
+ const SetSegmentedPricesScreen = (props: { theme?: ThemeType }) => {
20
+ const params = useRoute().params as SetSegmentedPricesParams
21
+ const navigation = useNavigation()
22
+ const {
23
+ uiState, showAddPopup, showEditPopup, hidePopup, insertPriceSegment, parsePriceSegmentText,
24
+ verifyEditingPriceSegment, handleSaveClick, getSegmentColor, styles, showCurrencyPopup, deletePriceSegment
25
+ } = useSetSegmentPrice(params, props.theme)
26
+
27
+ return (
28
+ <Page
29
+ style={{position: 'relative'}}
30
+ backText={params.backTitle}
31
+ headlineText={I18n.getLang('consumption_data_field_dynamic_money')}
32
+ rightButtonIcon={res.ic_check}
33
+ rightButtonIconClick={() => {
34
+ handleSaveClick(() => {
35
+ navigation.goBack()
36
+ })
37
+ }}
38
+ loading={uiState.isLoading}
39
+ showBackDialog={uiState.isDataChanged}
40
+ backDialogTitle={I18n.getLang('manage_user_unsaved_changes_dialog_headline')}
41
+ backDialogContent={I18n.getLang('motion_detection_change_activity_dialog_description_text')}
42
+ >
43
+ <ScrollView style={styles.scrollContainer}
44
+ contentContainerStyle={{flexGrow: 1, paddingBottom: cx(20)}}>
45
+ {/* currency field*/}
46
+ <Card style={{elevation: cx(2)}}
47
+ containerStyle={styles.currencyCardContainer}>
48
+ <Text
49
+ style={styles.cardTitle}>{I18n.getLang('consumption_data_price_per_kwh_currency_headline_text')}</Text>
50
+ <TouchableOpacity style={{flex: 0.4}} accessibilityLabel={"TextFieldStyleButton"}
51
+ onPress={showCurrencyPopup}>
52
+ <View style={styles.textGroup}>
53
+ <View style={styles.textParent}>
54
+ <Text style={styles.text}>{uiState.priceUnit}</Text>
55
+ </View>
56
+ <View style={styles.line}/>
57
+ </View>
58
+ </TouchableOpacity>
59
+ </Card>
60
+ {/* segmented prices field*/}
61
+ <View style={{width: '100%', marginTop: cx(10)}}>
62
+ <View style={{flexDirection: 'row', width: '100%', position: 'relative', marginTop: cx(10)}}>
63
+ {/*Vertical indicator*/}
64
+ <View style={styles.verticalIndicator}>
65
+ <VerticalDailyPricesIndicator
66
+ style={{height: '100%'}}
67
+ data={uiState.segments}
68
+ selectingData={uiState.selectedSegmentprice}
69
+ getSegmentColor={getSegmentColor}
70
+ />
71
+ </View>
72
+
73
+
74
+ <View style={{flex: 1, marginLeft: cx(45)}}>
75
+ {/* Add Time period */}
76
+ <Card
77
+ style={[styles.cardStyle, {marginTop: cx(8), marginLeft: cx(24), marginRight: cx(24)}]}
78
+ onPress={showAddPopup}>
79
+ <View style={{flexDirection: 'row', alignItems: 'center'}}>
80
+ <View style={styles.addPeriodCircle}>
81
+ <Image source={{uri: res.ic_plus}} style={styles.addPeriodImg}></Image>
82
+ </View>
83
+ <Spacer style={{width: cx(8)}}></Spacer>
84
+ <Text
85
+ style={{color: props.theme?.global.fontColor}}>{I18n.getLang('consumption_add_time_period')}</Text>
86
+ </View>
87
+ </Card>
88
+ {/** Time and Price segments */}
89
+ <Spacer/>
90
+ {
91
+ (uiState.segments.map((item) => {
92
+ const {startMinute, endMinute, price} = item
93
+ const content = parsePriceSegmentText(startMinute, endMinute, price, uiState.priceUnit)
94
+ const backgroundColor = getSegmentColor(item.startMinute, item.price)
95
+ return (
96
+ <View
97
+ key={`${item.startMinute}-${item.endMinute}`}
98
+ style={styles.segmentItem}
99
+ >
100
+ {/* Segment Card*/}
101
+ <Card
102
+ style={[styles.cardStyle, {
103
+ backgroundColor: backgroundColor,
104
+ marginTop: 0,
105
+ marginLeft: 0,
106
+ marginRight: 0,
107
+ width: '100%',
108
+ }]}
109
+ onPress={() => {
110
+ console.log("outside set selectedItem ->", JSON.stringify(item))
111
+ uiState.selectedSegmentprice = item
112
+ }}>
113
+ <View
114
+ style={{flexDirection: 'row', justifyContent: 'space-between',}}>
115
+ <Text
116
+ style={{color: props.theme?.button.fontColor}}>{content}</Text>
117
+ <TouchableOpacity
118
+ style={styles.editSegmentIndicator}
119
+ onPress={() => {
120
+ showEditPopup(item)
121
+ }}>
122
+ <Image
123
+ style={{
124
+ width: '100%',
125
+ height: '100%',
126
+ tintColor: props.theme?.button.fontColor
127
+ }} source={{uri: res.ic_more}}/>
128
+ </TouchableOpacity>
129
+ </View>
130
+
131
+ </Card>
132
+ {/* Delete icon*/}
133
+ {uiState.selectedSegmentprice?.startMinute === item.startMinute &&
134
+ uiState.selectedSegmentprice?.endMinute === item.endMinute && (
135
+ <TouchableOpacity
136
+ style={styles.deleteSegmentIndicator}
137
+ onPress={() => {
138
+ deletePriceSegment(item)
139
+ }}>
140
+ <Image style={{
141
+ width: '100%',
142
+ height: '100%',
143
+ }} source={res.icon_delete}/>
144
+ </TouchableOpacity>
145
+ )}
146
+
147
+ </View>
148
+ )
149
+ }
150
+ ))
151
+ }
152
+ </View>
153
+ </View>
154
+
155
+ </View>
156
+ </ScrollView>
157
+ <EnergyPopup
158
+ visible={uiState.editingSegmentPrice.isShow}
159
+ popupType={'segment_price'}
160
+ title={I18n.getLang('contact_sensor_specific_settings')}
161
+ cancelText={I18n.getLang('auto_scan_system_cancel')}
162
+ confirmText={I18n.getLang('auto_scan_system_wifi_confirm')}
163
+ editingPriceSegment={uiState.editingSegmentPrice.editingSegmentPrice}
164
+ onCancel={() => {
165
+ hidePopup()
166
+ }}
167
+ onPriceSegmentConfirm={(segment) => {
168
+ if (verifyEditingPriceSegment(segment)) {
169
+ hidePopup()
170
+ if (segment) {
171
+ console.log("wayne_check onPriceSegmentConfirm ->", JSON.stringify(segment))
172
+ insertPriceSegment(segment)
173
+ }
174
+ }
175
+ }}
176
+ />
177
+ </Page>
178
+ )
179
+ }
180
+
181
+ export interface SetSegmentedPricesParams {
182
+ backTitle: string,
183
+ dynamicData: DynamicEnergyData,
184
+ priceUnit: string,
185
+ isGenerate: boolean,
186
+ onSetPriceSements: (segments: PriceSegment[], currency: string) => Promise<boolean>,
187
+ }
188
+
189
+ export default withTheme(SetSegmentedPricesScreen)