Skip to content

Commit d64843e

Browse files
ethansharInbal-TishM-i-k-e-l
authored
Feat/calendar (#2403)
* Initial creation of new calendar component * Push typings * render initial components for example screen * Add DateUtils with stubs * Add tests for DateUtils * Add stub implementation to getDaysOfWeekNumber * Day - render day from date * Basic implementation of getWeekNumbersOfMonth * Install date-fns * Basic implementation of Month * Add Week * Fix Month component * Change to millis * Add key to week * Fix hardcoded month value we pass to Month component * Add test for invalid month * Render header in calendar item * Header - render header * fix CalendarItem render * Create getDaysOfWeekNumber and dependencies * radability * Add context provider and change firstDayOfWeek to enum * Add date and setDate to context * Add getDateObject worklet * Fix calendar context types and add initialDate prop * Basic Agenda * Header - add months by presing arrow buttons * Add TODOs * Move FirstDayOfWeek from enum to enum+union and fix tests * MILLIS to MS * Add mock data (script still needs work) and link (types need work) * Fix script and types * addMonth - use Date's setMonth function * Moving 'firstDayOfWeek' to be enum * WeekDaysNames - adding new component to Header * add ts notes * update comment for ts version ^4.3.2 * Set WeekDaysNames format in Header * use FirstDayOfWeek enum * use 'DayNamesFormat' enum * remove console * Day - adding onPress Adding style.ts file Calendar - pass date to calendatItem * add todo * Minor fixes and remove todos * Change context's date to selectedDate * remove style file * valueOf to getTime * export Event * Day - adding selection * Wrap CalendarItem with FlashList * Agenda - add headers + scroll on date change * Agenda - set date on scroll * Fix mock and script * UI touchups * sort imports and add test for 'getMonthForIndex' and adding tests to 'isSameDay' * Header - small ui changes * Context - adding 'showWeeksNumbers' * fix indentation * margin * Add TODOs * Add flex to day * Day - mark today * Fix FirstDayOfWeek enum and add update sources * Adding UpdateSource to setDate calls * Implement setDate to store lastUpdateSource * dateutils - adding 'isSameMonth' * Add estimatedItemSize prop to FlashLists * Update mock data * Fix TS errors * Context - add updateSource * fix screen errors * fix updateSource on Context * fix type * Calendar - add scrollToIndex on selectedDate change * Agenda - verify update source and use isSameDay * Calendar - fix scrollToIndex * Agenda initial index * isSameMonth - support MonthProps type for params * remove synchronous calls to isNumber from a worklet * Calendar - add setDate on calendar scroll (by user) to the first of the month * Fix getWeekNumbersOfMonth and add missing tests * Fix typing issues * fix scrolledByUser.value * Calendar - fix scrolledByUser * merge fix * shush ts wrannings * Add todos * DateUtils - add 'getNormalizedDate' function * Change week numbering from ISO to default Change week numbering to use "default" (Jan 1st) and not ISO (Jan 4th) * DateUtils - adding 'isToday' and 'isPastDate' * adding assets * Agenda - fixed height for items and initialScrollIndex * TodayButton * Update calendars mock data * Adding 'staticHeader' prop to calendar and passing it on context * Fix lint * HeaderHeight to context * Utils - use JS Date only inside utils (not as a parameter nor return type) and fix tests to use our inner utils * Fix tests and remove getNormalizedDate * fix lint * Calendar - setDate when 'initialDate' prop changes * Header - fix arrows * fix margins --------- Co-authored-by: Inbal Tish <[email protected]> Co-authored-by: M-i-k-e-l <[email protected]>
1 parent ae49223 commit d64843e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1530
-0
lines changed

demo/src/screens/MenuStructure.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export const navigationData = {
174174
Incubator: {
175175
title: 'Incubator (Experimental)',
176176
screens: [
177+
{title: 'Calendar', tags: 'calendar', screen: 'unicorn.components.IncubatorCalendarScreen'},
177178
{title: 'ChipsInput (New)', tags: 'chips input', screen: 'unicorn.components.IncubatorChipsInputScreen'},
178179
{title: 'Native TouchableOpacity', tags: 'touchable native', screen: 'unicorn.incubator.TouchableOpacityScreen'},
179180
{title: 'Dialog (New)', tags: 'dialog modal popup alert', screen: 'unicorn.incubator.IncubatorDialogScreen'},

demo/src/screens/incubatorScreens/IncubatorCalendarScreen/MockData.ts

Lines changed: 2 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React, {Component} from 'react';
2+
import {View, Incubator} from 'react-native-ui-lib';
3+
import {data} from './MockData';
4+
5+
export default class CalendarScreen extends Component {
6+
// constructor(props) {
7+
// super(props);
8+
9+
// setTimeout(() => {
10+
// this.setState({date: 1676026748000});
11+
// }, 2000);
12+
// }
13+
14+
state = {
15+
date: undefined
16+
};
17+
18+
render() {
19+
return (
20+
<View flex>
21+
<Incubator.Calendar data={data} staticHeader initialDate={this.state.date}>
22+
<Incubator.Calendar.Agenda/>
23+
</Incubator.Calendar>
24+
</View>
25+
);
26+
}
27+
}

demo/src/screens/incubatorScreens/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
22

33
export function registerScreens(registrar) {
4+
registrar('unicorn.components.IncubatorCalendarScreen', () => require('./IncubatorCalendarScreen').default);
45
registrar('unicorn.components.IncubatorChipsInputScreen', () => require('./IncubatorChipsInputScreen').default);
56
registrar('unicorn.incubator.TouchableOpacityScreen', () =>
67
gestureHandlerRootHOC(require('./TouchableOpacityScreen').default));

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"pre-push": "npm run build:dev && npm run test",
3333
"docs:deploy": "./scripts/deployDocs.sh",
3434
"docs:build": "node scripts/buildDocs.js",
35+
"calendar:createMocks": "node scripts/createCalendarMockData.js",
3536
"snippets:build": "node scripts/generateSnippets.js",
3637
"demo": "./scripts/demo.sh",
3738
"release": "node ./scripts/release.js"
@@ -40,6 +41,7 @@
4041
"babel-plugin-transform-inline-environment-variables": "^0.0.2",
4142
"color": "^3.1.0",
4243
"commons-validator-js": "^1.0.237",
44+
"date-fns": "^2.29.3",
4345
"deprecated-react-native-prop-types": "^2.3.0",
4446
"hoist-non-react-statics": "^3.0.0",
4547
"lodash": "^4.17.21",

scripts/createCalendarMockData.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const fs = require('fs');
2+
const HOUR_IN_MS = 60 * 60 * 1000;
3+
const ID_LENGTH = 10;
4+
5+
function generateId() {
6+
let result = '';
7+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
8+
const charactersLength = characters.length;
9+
for (let i = 0; i < ID_LENGTH; i++) {
10+
result += characters.charAt(Math.floor(Math.random() * charactersLength));
11+
}
12+
return result;
13+
}
14+
15+
const data = [];
16+
17+
for (let year = 2021; year <= 2023; ++year) {
18+
for (let month = 0; month <= 11; ++month) {
19+
for (let day = 1; day <= 31; day += Math.random() > 0.5 ? 2 : 1) {
20+
for (let hour = 9; hour <= 19; hour += Math.random() > 0.5 ? 4 : 3) {
21+
const startDate = new Date(year, month, day, hour, 0);
22+
if (startDate.getDay() >= 2 && startDate.getDay() <= 5) {
23+
const start = startDate.getTime();
24+
const end = start + HOUR_IN_MS * (Math.random() > 0.5 ? 0.5 : 1);
25+
const id = generateId();
26+
data.push({id, start, end});
27+
}
28+
}
29+
}
30+
}
31+
}
32+
33+
console.log(`${data.length} events were created`);
34+
35+
fs.writeFileSync('demo/src/screens/incubatorScreens/IncubatorCalendarScreen/MockData.ts',
36+
`// Note: to generate new data run calendar:createMocks and update createCalendarMockData script \n` +
37+
`export const data = ${JSON.stringify(data)};`);

src/incubator/Calendar/Agenda.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React, {useContext, useCallback, useRef} from 'react';
2+
import {runOnJS, useAnimatedReaction, useSharedValue} from 'react-native-reanimated';
3+
import {FlashList, ViewToken} from '@shopify/flash-list';
4+
import {BorderRadiuses} from 'style';
5+
import View from '../../components/view';
6+
import Text from '../../components/text';
7+
import {isSameDay, isSameMonth} from './helpers/DateUtils';
8+
import {InternalEvent, Event, DateSectionHeader, UpdateSource} from './types';
9+
import CalendarContext from './CalendarContext';
10+
11+
// TODO: Fix initial scrolling
12+
function Agenda() {
13+
const {data, selectedDate, setDate, updateSource} = useContext(CalendarContext);
14+
const flashList = useRef<FlashList<InternalEvent>>(null);
15+
const closestSectionHeader = useSharedValue<DateSectionHeader | null>(null);
16+
const scrolledByUser = useSharedValue<boolean>(false);
17+
18+
const keyExtractor = useCallback((item: InternalEvent) => {
19+
return item.type === 'Event' ? item.id : item.header;
20+
}, []);
21+
22+
const renderEvent = useCallback((item: Event) => {
23+
return (
24+
<View
25+
marginV-1
26+
marginH-10
27+
paddingH-10
28+
height={50}
29+
style={{borderWidth: 1, borderRadius: BorderRadiuses.br20, justifyContent: 'center'}}
30+
>
31+
<Text style={{}}>
32+
Item for{' '}
33+
{new Date(item.start).toLocaleString('en-GB', {
34+
month: 'short',
35+
day: 'numeric',
36+
hour12: false,
37+
hour: '2-digit',
38+
minute: '2-digit'
39+
})}
40+
-{new Date(item.end).toLocaleString('en-GB', {hour12: false, hour: '2-digit', minute: '2-digit'})}
41+
</Text>
42+
</View>
43+
);
44+
}, []);
45+
46+
const renderHeader = useCallback((item: DateSectionHeader) => {
47+
return (
48+
<View
49+
marginB-1
50+
paddingB-4
51+
marginH-10
52+
paddingH-10
53+
height={50}
54+
bottom
55+
>
56+
<Text>{item.header}</Text>
57+
</View>
58+
);
59+
}, []);
60+
61+
const renderItem = useCallback(({item}: {item: InternalEvent; index: number}) => {
62+
switch (item.type) {
63+
case 'Event':
64+
return renderEvent(item);
65+
case 'Header':
66+
return renderHeader(item);
67+
}
68+
},
69+
[renderEvent, renderHeader]);
70+
71+
const getItemType = useCallback(item => item.type, []);
72+
73+
const findClosestDateAfter = useCallback((selected: number) => {
74+
'worklet';
75+
for (let index = 0; index < data.length; ++index) {
76+
const item = data[index];
77+
if (item.type === 'Header') {
78+
if (item.date >= selected) {
79+
return {dateSectionHeader: item, index};
80+
}
81+
}
82+
}
83+
84+
return null;
85+
},
86+
[data]);
87+
88+
const scrollToIndex = useCallback((index: number, animated: boolean) => {
89+
flashList.current?.scrollToIndex({index, animated});
90+
}, []);
91+
92+
useAnimatedReaction(() => {
93+
return selectedDate.value;
94+
},
95+
(selected, previous) => {
96+
if (updateSource?.value !== UpdateSource.AGENDA_SCROLL) {
97+
if (
98+
selected !== previous &&
99+
(closestSectionHeader.value?.date === undefined || !isSameDay(selected, closestSectionHeader.value?.date))
100+
) {
101+
const result = findClosestDateAfter(selected);
102+
if (result !== null) {
103+
const {dateSectionHeader, index} = result;
104+
closestSectionHeader.value = dateSectionHeader;
105+
scrolledByUser.value = false;
106+
// TODO: Can the animation be improved (not in JS)?
107+
if (previous) {
108+
const _isSameMonth = isSameMonth(selected, previous);
109+
runOnJS(scrollToIndex)(index, _isSameMonth);
110+
}
111+
}
112+
}
113+
}
114+
},
115+
[findClosestDateAfter]);
116+
117+
// TODO: look at https://docs.swmansion.com/react-native-reanimated/docs/api/hooks/useAnimatedScrollHandler
118+
const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => {
119+
if (scrolledByUser.value) {
120+
const result = viewableItems.find(item => item.item.type === 'Header');
121+
if (result) {
122+
const {item}: {item: DateSectionHeader} = result;
123+
if (closestSectionHeader.value?.date !== item.date) {
124+
closestSectionHeader.value = item;
125+
setDate(item.date, UpdateSource.AGENDA_SCROLL);
126+
}
127+
}
128+
}
129+
// eslint-disable-next-line react-hooks/exhaustive-deps
130+
}, []);
131+
132+
const onMomentumScrollBegin = useCallback(() => {
133+
scrolledByUser.value = true;
134+
// eslint-disable-next-line react-hooks/exhaustive-deps
135+
}, []);
136+
137+
const onScrollBeginDrag = useCallback(() => {
138+
scrolledByUser.value = true;
139+
// eslint-disable-next-line react-hooks/exhaustive-deps
140+
}, []);
141+
142+
return (
143+
<FlashList
144+
ref={flashList}
145+
estimatedItemSize={52}
146+
data={data}
147+
keyExtractor={keyExtractor}
148+
renderItem={renderItem}
149+
getItemType={getItemType}
150+
onViewableItemsChanged={onViewableItemsChanged}
151+
onMomentumScrollBegin={onMomentumScrollBegin}
152+
onScrollBeginDrag={onScrollBeginDrag}
153+
initialScrollIndex={findClosestDateAfter(selectedDate.value)?.index ?? 0}
154+
/>
155+
);
156+
}
157+
158+
export default Agenda;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {createContext} from 'react';
2+
import {CalendarContextProps, FirstDayOfWeek} from './types';
3+
4+
// @ts-ignore
5+
const CalendarContext = createContext<CalendarContextProps>({
6+
firstDayOfWeek: FirstDayOfWeek.MONDAY
7+
});
8+
9+
export default CalendarContext;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, {useContext, useMemo} from 'react';
2+
import {StyleSheet} from 'react-native';
3+
import {Constants} from '../../commons/new';
4+
import View from '../../components/view';
5+
import {CalendarItemProps} from './types';
6+
import CalendarContext from './CalendarContext';
7+
import Month from './Month';
8+
import Header from './Header';
9+
10+
const CALENDAR_HEIGHT = 250;
11+
12+
function CalendarItem(props: CalendarItemProps) {
13+
const {year, month} = props;
14+
const {staticHeader, headerHeight} = useContext(CalendarContext);
15+
16+
const calendarStyle = useMemo(() => {
17+
// TODO: dynamic height: calc calendar height with month's number of weeks
18+
return [
19+
styles.container,
20+
{
21+
height: CALENDAR_HEIGHT - (staticHeader ? headerHeight.value : 0)
22+
}
23+
];
24+
}, [staticHeader]);
25+
26+
if (month !== undefined) {
27+
return (
28+
<View style={calendarStyle}>
29+
{!staticHeader && <Header month={month} year={year}/>}
30+
<Month month={month} year={year}/>
31+
</View>
32+
);
33+
}
34+
return null;
35+
}
36+
37+
export default CalendarItem;
38+
39+
const styles = StyleSheet.create({
40+
container: {
41+
width: Constants.screenWidth,
42+
borderBottomWidth: 1
43+
}
44+
});

src/incubator/Calendar/Day.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import isNull from 'lodash/isNull';
2+
import React, {useContext, useCallback} from 'react';
3+
import {StyleSheet} from 'react-native';
4+
import Reanimated, {useSharedValue, useAnimatedStyle, useAnimatedReaction} from 'react-native-reanimated';
5+
import {Colors} from 'style';
6+
import View from '../../components/view';
7+
import TouchableOpacity from '../../components/touchableOpacity';
8+
import Text from '../../components/text';
9+
import {getDayOfDate, isSameDay, isToday} from './helpers/DateUtils';
10+
import {DayProps, UpdateSource} from './types';
11+
import CalendarContext from './CalendarContext';
12+
13+
14+
const AnimatedText = Reanimated.createAnimatedComponent(Text);
15+
16+
const Day = (props: DayProps) => {
17+
const {date, onPress} = props;
18+
const {selectedDate, setDate} = useContext(CalendarContext);
19+
20+
const shouldMarkSelected = !isNull(date) ? isSameDay(selectedDate.value, date) : false;
21+
const isSelected = useSharedValue(shouldMarkSelected);
22+
23+
const backgroundColor = isToday(date) ? Colors.$backgroundSuccessHeavy : Colors.transparent;
24+
const textColor = isToday(date) ? Colors.$textDefaultLight : Colors.$backgroundPrimaryHeavy;
25+
26+
const animatedStyles = useAnimatedStyle(() => {
27+
return {
28+
backgroundColor: isSelected.value ? Colors.$backgroundPrimaryHeavy : backgroundColor,
29+
color: isSelected.value ? Colors.$textDefaultLight : textColor
30+
};
31+
});
32+
33+
const animatedTextStyles = useAnimatedStyle(() => {
34+
return {
35+
color: isSelected.value ? Colors.$textDefaultLight : textColor
36+
};
37+
});
38+
39+
useAnimatedReaction(() => {
40+
return selectedDate.value;
41+
}, (selected) => {
42+
isSelected.value = isSameDay(selected, date!);
43+
}, []);
44+
45+
const _onPress = useCallback(() => {
46+
if (date !== null) {
47+
isSelected.value = true;
48+
setDate(date, UpdateSource.DAY_SELECT);
49+
onPress?.(date);
50+
}
51+
}, [date, setDate, onPress]);
52+
53+
const renderDay = () => {
54+
const day = !isNull(date) ? getDayOfDate(date) : '';
55+
return (
56+
<View center>
57+
<View reanimated style={[styles.selection, animatedStyles]}/>
58+
<AnimatedText style={animatedTextStyles}>{day}</AnimatedText>
59+
</View>
60+
);
61+
};
62+
63+
return (
64+
<TouchableOpacity flex center style={styles.dayContainer} onPress={_onPress} activeOpacity={1}>
65+
{renderDay()}
66+
</TouchableOpacity>
67+
);
68+
};
69+
70+
export default Day;
71+
72+
const styles = StyleSheet.create({
73+
dayContainer: {
74+
width: 32,
75+
height: 32
76+
},
77+
selection: {
78+
position: 'absolute',
79+
width: 24,
80+
height: 24,
81+
borderRadius: 12
82+
}
83+
});

0 commit comments

Comments
 (0)