Support for pagination, refresh, empty placeholders

This commit is contained in:
Nouman Tahir 2021-04-07 23:50:00 +05:00
parent 1fc8b323e0
commit 258e5d0b09
10 changed files with 739 additions and 107 deletions

View File

@ -1,18 +1,28 @@
import React, {forwardRef, memo, useRef, useImperativeHandle, useState, useEffect} from 'react'
import PostCard from '../../postCard';
import { get } from 'lodash';
import { FlatListProps, FlatList } from 'react-native';
import { FlatListProps, FlatList, RefreshControl, ActivityIndicator, View } from 'react-native';
import { useSelector } from 'react-redux';
import { ThemeContainer } from '../../../containers';
import { PostCardPlaceHolder } from '../..';
import styles from '../view/postsListStyles';
import { isDarkTheme } from '../../../redux/actions/applicationActions';
interface postsListContainerProps extends FlatListProps<any> {
promotedPosts:Array<any>;
isFeedScreen:boolean;
onLoadPosts?:(shouldReset:boolean)=>void;
isLoading:boolean;
isRefreshing:boolean;
}
const postsListContainer = ({
promotedPosts,
isFeedScreen,
onLoadPosts,
isRefreshing,
isLoading,
...props
}:postsListContainerProps, ref) => {
const flatListRef = useRef(null);
@ -65,6 +75,23 @@ const postsListContainer = ({
}
}
const _renderFooter = () => {
if (isLoading) {
return (
<View style={styles.flatlistFooter}>
<ActivityIndicator animating size="large" color={'#2e3d51'} />
</View>
);
}
return null;
};
const _renderItem = ({ item, index }:{item:any, index:number}) => {
const e = [];
@ -109,25 +136,39 @@ const postsListContainer = ({
};
return (
<FlatList
ref={flatListRef}
data={posts}
showsVerticalScrollIndicator={false}
renderItem={_renderItem}
keyExtractor={(content) => content.permlink}
removeClippedSubviews
onEndReachedThreshold={1}
maxToRenderPerBatch={3}
initialNumToRender={3}
windowSize={5}
extraData={imageHeights}
{...props}
/>
<ThemeContainer>
{({ isDarkTheme }) => (
<FlatList
ref={flatListRef}
data={posts}
showsVerticalScrollIndicator={false}
renderItem={_renderItem}
keyExtractor={(content) => content.permlink}
removeClippedSubviews
onEndReachedThreshold={1}
maxToRenderPerBatch={3}
initialNumToRender={3}
windowSize={5}
extraData={imageHeights}
onEndReached={()=>{if(onLoadPosts){onLoadPosts(false)}}}
ListFooterComponent={_renderFooter}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={()=>{if(onLoadPosts){onLoadPosts(true)}}}
progressBackgroundColor="#357CE6"
tintColor={!isDarkTheme ? '#357ce6' : '#96c0ff'}
titleColor="#fff"
colors={['#fff']}
/>
}
{...props}
/>
)}
</ThemeContainer>
)
}
export default memo(forwardRef(postsListContainer));
export default forwardRef(postsListContainer);

View File

@ -0,0 +1,14 @@
import EStyleSheet from 'react-native-extended-stylesheet';
export default EStyleSheet.create({
flatlistFooter: {
alignContent: 'center',
alignItems: 'center',
marginTop: 10,
marginBottom: 60,
borderColor: '$borderColor',
},
placeholderWrapper: {
flex: 1,
},
})

View File

@ -3,10 +3,11 @@ import { useIntl } from 'react-intl';
import ScrollableTabView from 'react-native-scrollable-tab-view';
import { useDispatch, useSelector } from 'react-redux';
import PostsList from '../../postsList';
import { loadPosts, LoadPostsOptions } from '../services/tabbedPostsFetch';
import { loadPosts } from '../services/tabbedPostsFetch';
import { TabbedPostsProps } from '../services/tabbedPostsModels';
import { cacheReducer, initCacheState, setSelectedFilter, PostsCache } from '../services/tabbedPostsReducer';
import { StackedTabBar, TabItem } from '../view/stackedTabBar';
import { TabbedPostsProps } from './tabbedPostsProps';
import TabContent from '../view/tabContent';
export const TabbedPosts = ({
@ -20,13 +21,6 @@ export const TabbedPosts = ({
...props
}:TabbedPostsProps) => {
const intl = useIntl();
const isLoggedIn = useSelector((state) => state.application.isLoggedIn);
//redux properties
const isAnalytics = useSelector((state) => state.application.isAnalytics);
const nsfw = useSelector((state) => state.application.nsfw);
const isConnected = useSelector((state) => state.application.isConnected);
//initialize state
@ -59,45 +53,22 @@ export const TabbedPosts = ({
//initialize first set of pages
const pages = combinedFilters.map((filter)=>(
<PostsList
data={cache.cachedData[filter.filterKey].posts}
<TabContent
filterKey={filter.filterKey}
tabLabel={filter.label}
isFeedScreen={isFeedScreen}
promotedPosts={[]}
feedUsername={feedUsername}
{...props}
/>
))
//side effects
useEffect(() => {
_loadPosts()
}, [])
//load posts implementation
const _loadPosts = (filter?:string) => {
const options = {
passedFilter:filter,
cache,
cacheDispatch,
isLoggedIn,
isAnalytics,
nsfw,
isConnected,
feedUsername,
isFeedScreen,
...props
} as LoadPostsOptions
loadPosts(options)
}
//actions
const _onFilterSelect = (filter:string) => {
cacheDispatch(setSelectedFilter(filter))
if(cache.cachedData[filter].posts.length === 0){
_loadPosts(filter);
// _loadPosts(filter);
}
}

View File

@ -1,12 +0,0 @@
export interface TabbedPostsProps {
filterOptions:string[],
filterOptionsValue:string[],
isFeedScreen:boolean,
feedUsername:string,
initialFilterIndex:number,
feedSubfilterOptions:string[],
feedSubfilterOptionsValue:string[],
getFor:string,
pageType:string,
tag:string,
}

View File

@ -1,29 +1,15 @@
import { Dispatch, ReducerAction } from "react";
import { getAccountPosts, getRankedPosts } from "../../../providers/hive/dhive";
import { CachedDataEntry, onLoadComplete, PostsCache, setFilterLoading, updateFilterCache } from "./tabbedPostsReducer";
import { getUpdatedPosts } from "./tabbedPostsReducer";
import Matomo from 'react-native-matomo-sdk';
import { LoadPostsOptions } from "./tabbedPostsModels";
export interface LoadPostsOptions {
cache:PostsCache,
cacheDispatch:Dispatch<ReducerAction<any>>,
getFor:string,
isConnected:boolean,
isLoggedIn:boolean,
feedUsername:string,
pageType:string,
tag:string,
nsfw:string,
isAnalytics:boolean,
isLatestPostCheck?:boolean,
refreshing?:boolean,
passedFilter?:string;
}
const POSTS_FETCH_COUNT = 20;
export const loadPosts = async ({
passedFilter,
cache,
cacheDispatch,
filterKey,
prevPosts,
tabMeta,
setTabMeta,
isLatestPostCheck = false,
getFor,
isConnected,
@ -36,33 +22,41 @@ export const loadPosts = async ({
isAnalytics
}:LoadPostsOptions) => {
let filter = passedFilter || cache.selectedFilter;
let filter = filterKey;
//match filter with api if is friends
if(filter === 'friends'){
filter = 'feed';
}
const {isLoading, startPermlink, startAuthor}:CachedDataEntry = cache.cachedData[filter];
const {isLoading, startPermlink, startAuthor} = tabMeta;
if (
isLoading ||
!isConnected ||
(!isLoggedIn && passedFilter === 'feed') ||
(!isLoggedIn && passedFilter === 'blog')
(!isLoggedIn && filterKey === 'feed') ||
(!isLoggedIn && filterKey === 'blog')
) {
return;
}
if (!isConnected && (refreshing || isLoading)) {
cacheDispatch(onLoadComplete(filter))
setTabMeta({
...tabMeta,
isLoading:false,
isRefreshing:false,
})
return;
}
cacheDispatch(setFilterLoading(filter, true));
setTabMeta({
...tabMeta,
isLoading:true,
isRefreshing:refreshing,
})
let options = {} as any;
const limit = isLatestPostCheck ? 5 : 20;
const limit = isLatestPostCheck ? 5 : POSTS_FETCH_COUNT;
let func = null;
if (
@ -122,12 +116,30 @@ export const loadPosts = async ({
if(filter === 'feed'){
filter = 'friends'
}
cacheDispatch(updateFilterCache(filter, result, refreshing))
cacheDispatch(onLoadComplete(filter));
// cacheDispatch(updateFilterCache(filter, result, refreshing))
setTabMeta({
...tabMeta,
isLoading:false,
isRefreshing:false,
})
return getUpdatedPosts(
prevPosts,
result,
refreshing,
tabMeta,
setTabMeta
)
} catch (err) {
cacheDispatch(onLoadComplete(filter));
setTabMeta({
...tabMeta,
isLoading:false,
isRefreshing:false,
})
}
// track filter and tag views

View File

@ -0,0 +1,40 @@
export interface TabbedPostsProps {
filterOptions:string[],
filterOptionsValue:string[],
isFeedScreen:boolean,
feedUsername:string,
initialFilterIndex:number,
feedSubfilterOptions:string[],
feedSubfilterOptionsValue:string[],
getFor:string,
pageType:string,
tag:string,
}
export interface TabMeta {
startPermlink:string,
startAuthor:string,
isLoading:boolean,
isRefreshing:boolean,
isNoPost:boolean,
}
export interface LoadPostsOptions {
filterKey:string;
prevPosts:any[];
tabMeta:TabMeta;
setTabMeta:(meta:TabMeta)=>void,
getFor:string,
isConnected:boolean,
isLoggedIn:boolean,
feedUsername:string,
pageType:string,
tag:string,
nsfw:string,
isAnalytics:boolean,
isLatestPostCheck?:boolean,
refreshing?:boolean,
}

View File

@ -1,5 +1,7 @@
import { TabItem } from "../view/stackedTabBar";
import unionBy from 'lodash/unionBy';
import { TabMeta } from "./tabbedPostsModels";
import TabBarTop from "react-navigation-tabs/lib/typescript/src/views/MaterialTopTabBar";
export const CacheActions = {
SET_FILTER_LOADING:'SET_FILTER_LOADING',
@ -53,14 +55,64 @@ export const onLoadComplete = (filter:string) => ({
type:CacheActions.ON_LOAD_COMPLETE
})
export const updateFilterCache = (filter:string, posts:any[], refreshing:boolean) => ({
payload: {
filter,
posts,
shouldReset: refreshing,
},
type: CacheActions.UPDATE_FILTER_CACHE,
})
export const getUpdatedPosts = (prevPosts:any[], nextPosts:any[], shouldReset:boolean, tabMeta:TabMeta, setTabMeta:(meta:TabMeta)=>void) => {
//return state as is if component is unmounter
let _posts = nextPosts;
// const isFeedScreen = state.isFeedScreen
// const cachedEntry:CachedDataEntry = state.cachedData[filter];
// if (!cachedEntry) {
// throw new Error('No cached entry available');
// }
if(nextPosts.length === 0){
setTabMeta({
...tabMeta,
isNoPost:true
});
return prevPosts;
}
const refreshing = tabMeta.isRefreshing;
if (prevPosts.length > 0 && !shouldReset) {
if (refreshing) {
_posts = unionBy(_posts, prevPosts, 'permlink');
} else {
_posts = unionBy(prevPosts, _posts, 'permlink');
}
}
//cache latest posts for main tab for returning user
// else if (isFeedScreen) {
// //schedule refetch of new posts by checking time of current post
// _scheduleLatestPostsCheck(nextPosts[0]);
// if (filter == (get(currentAccount, 'name', null) == null ? 'hot' : 'friends')) {
// _setInitPosts(nextPosts);
// }
// }
//update stat
setTabMeta({
...tabMeta,
startAuthor:_posts[_posts.length - 1] && _posts[_posts.length - 1].author,
startPermlink: _posts[_posts.length - 1] && _posts[_posts.length - 1].permlink,
})
//dispatch to redux
// if (
// filter === (state.selectedFilter !== 'feed' ? state.selectedFilter : state.currentSubFilter)
// ) {
// _setFeedPosts(_posts);
// }
return _posts
}
export const initCacheState = (filters:TabItem[], selectedFilter:string, isFeedScreen:boolean) => {

View File

@ -0,0 +1,341 @@
import React, {useEffect, useState} from 'react';
import { useIntl } from 'react-intl';
import { get } from 'lodash';
import { Text, View, FlatList } from 'react-native';
import { NoPost, PostCardPlaceHolder, UserListItem } from '../..';
import globalStyles from '../../../globalStyles';
import { CommunityListItem } from '../../basicUIElements';
import styles from './tabbedPostsStyles';
import { default as ROUTES } from '../../../constants/routeNames';
import { withNavigation } from 'react-navigation';
import {useSelector, useDispatch } from 'react-redux';
import { fetchCommunities, leaveCommunity, subscribeCommunity } from '../../../redux/actions/communitiesAction';
import { fetchLeaderboard, followUser, unfollowUser } from '../../../redux/actions/userAction';
import { getCommunity } from '../../../providers/hive/dhive';
interface TabEmptyViewProps {
filterKey:string,
isNoPost:boolean,
navigation:any,
}
const TabEmptyView = ({
filterKey,
isNoPost,
navigation
}: TabEmptyViewProps) => {
const intl = useIntl();
const dispatch = useDispatch();
//redux properties
const isLoggedIn = useSelector((state) => state.application.isLoggedIn);
const subscribingCommunities = useSelector(
(state) => state.communities.subscribingCommunitiesInFeedScreen,
);
const [recommendedCommunities, setRecommendedCommunities] = useState([]);
const [recommendedUsers, setRecommendedUsers] = useState([]);
const followingUsers = useSelector((state) => state.user.followingUsersInFeedScreen);
const currentAccount = useSelector((state) => state.account.currentAccount);
const pinCode = useSelector((state) => state.application.pin);
const leaderboard = useSelector((state) => state.user.leaderboard);
const communities = useSelector((state) => state.communities.communities);
//hooks
useEffect(()=>{
if (isNoPost) {
if (filterKey === 'friends') {
if (recommendedUsers.length === 0) {
_getRecommendedUsers();
}
} else if(filterKey === 'communities') {
if (recommendedCommunities.length === 0) {
_getRecommendedCommunities();
}
}
}
}, [isNoPost])
useEffect(() => {
if (!leaderboard.loading) {
if (!leaderboard.error && leaderboard.data.length > 0) {
_formatRecommendedUsers(leaderboard.data);
}
}
}, [leaderboard]);
useEffect(() => {
if (!communities.loading) {
if (!communities.error && communities.data?.length > 0) {
_formatRecommendedCommunities(communities.data);
}
}
}, [communities]);
useEffect(() => {
const recommendeds = [...recommendedCommunities];
Object.keys(subscribingCommunities).map((communityId) => {
if (!subscribingCommunities[communityId].loading) {
if (!subscribingCommunities[communityId].error) {
if (subscribingCommunities[communityId].isSubscribed) {
recommendeds.forEach((item) => {
if (item.name === communityId) {
item.isSubscribed = true;
}
});
} else {
recommendeds.forEach((item) => {
if (item.name === communityId) {
item.isSubscribed = false;
}
});
}
}
}
});
setRecommendedCommunities(recommendeds);
}, [subscribingCommunities]);
useEffect(() => {
const recommendeds = [...recommendedUsers];
Object.keys(followingUsers).map((following) => {
if (!followingUsers[following].loading) {
if (!followingUsers[following].error) {
if (followingUsers[following].isFollowing) {
recommendeds.forEach((item) => {
if (item._id === following) {
item.isFollowing = true;
}
});
} else {
recommendeds.forEach((item) => {
if (item._id === following) {
item.isFollowing = false;
}
});
}
}
}
});
setRecommendedUsers(recommendeds);
}, [followingUsers]);
//fetching
const _getRecommendedUsers = () => dispatch(fetchLeaderboard());
const _getRecommendedCommunities = () => dispatch(fetchCommunities('', 10));
//formating
const _formatRecommendedCommunities = async (communitiesArray) => {
try {
const ecency = await getCommunity('hive-125125');
const recommendeds = [ecency, ...communitiesArray];
recommendeds.forEach((item) => Object.assign(item, { isSubscribed: false }));
setRecommendedCommunities(recommendeds);
} catch (err) {
console.log(err, '_getRecommendedUsers Error');
}
};
const _formatRecommendedUsers = (usersArray) => {
const recommendeds = usersArray.slice(0, 10);
recommendeds.unshift({ _id: 'good-karma' });
recommendeds.unshift({ _id: 'ecency' });
recommendeds.forEach((item) => Object.assign(item, { isFollowing: false }));
setRecommendedUsers(recommendeds);
};
//actions related routines
const _handleSubscribeCommunityButtonPress = (data) => {
let subscribeAction;
let successToastText = '';
let failToastText = '';
if (!data.isSubscribed) {
subscribeAction = subscribeCommunity;
successToastText = intl.formatMessage({
id: 'alert.success_subscribe',
});
failToastText = intl.formatMessage({
id: 'alert.fail_subscribe',
});
} else {
subscribeAction = leaveCommunity;
successToastText = intl.formatMessage({
id: 'alert.success_leave',
});
failToastText = intl.formatMessage({
id: 'alert.fail_leave',
});
}
dispatch(
subscribeAction(currentAccount, pinCode, data, successToastText, failToastText, 'feedScreen'),
);
};
const _handleFollowUserButtonPress = (data, isFollowing) => {
let followAction;
let successToastText = '';
let failToastText = '';
if (!isFollowing) {
followAction = followUser;
successToastText = intl.formatMessage({
id: 'alert.success_follow',
});
failToastText = intl.formatMessage({
id: 'alert.fail_follow',
});
} else {
followAction = unfollowUser;
successToastText = intl.formatMessage({
id: 'alert.success_unfollow',
});
failToastText = intl.formatMessage({
id: 'alert.fail_unfollow',
});
}
data.follower = get(currentAccount, 'name', '');
dispatch(followAction(currentAccount, pinCode, data, successToastText, failToastText));
};
const _handleOnPressLogin = () => {
navigation.navigate(ROUTES.SCREENS.LOGIN);
};
//render related operations
if ((filterKey === 'feed' || filterKey === 'blog') && !isLoggedIn) {
return (
<NoPost
imageStyle={styles.noImage}
isButtonText
defaultText={intl.formatMessage({
id: 'profile.login_to_see',
})}
handleOnButtonPress={_handleOnPressLogin}
/>
);
}
if (isNoPost) {
if (filterKey === 'friends') {
return (
<>
<Text style={[globalStyles.subTitle, styles.noPostTitle]}>
{intl.formatMessage({ id: 'profile.follow_people' })}
</Text>
<FlatList
data={recommendedUsers}
extraData={recommendedUsers}
keyExtractor={(item, index) => `${item._id || item.id}${index}`}
renderItem={({ item, index }) => (
<UserListItem
index={index}
username={item._id}
isHasRightItem
rightText={
item.isFollowing
? intl.formatMessage({ id: 'user.unfollow' })
: intl.formatMessage({ id: 'user.follow' })
}
//isRightColor={item.isFollowing}
isLoggedIn={isLoggedIn}
isFollowing={item.isFollowing}
isLoadingRightAction={
followingUsers.hasOwnProperty(item._id) && followingUsers[item._id].loading
}
onPressRightText={_handleFollowUserButtonPress}
handleOnPress={(username) =>
navigation.navigate({
routeName: ROUTES.SCREENS.PROFILE,
params: {
username,
},
key: username,
})
}
/>
)}
/>
</>
);
} else if (filterKey === 'communities') {
return (
<>
<Text style={[globalStyles.subTitle, styles.noPostTitle]}>
{intl.formatMessage({ id: 'profile.follow_communities' })}
</Text>
<FlatList
data={recommendedCommunities}
keyExtractor={(item, index) => `${item.id || item.title}${index}`}
renderItem={({ item, index }) => (
<CommunityListItem
index={index}
title={item.title}
about={item.about}
admins={item.admins}
id={item.id}
authors={item.num_authors}
posts={item.num_pending}
subscribers={item.subscribers}
isNsfw={item.is_nsfw}
name={item.name}
handleOnPress={(name) =>
navigation.navigate({
routeName: ROUTES.SCREENS.COMMUNITY,
params: {
tag: name,
},
})
}
handleSubscribeButtonPress={_handleSubscribeCommunityButtonPress}
isSubscribed={item.isSubscribed}
isLoadingRightAction={
subscribingCommunities.hasOwnProperty(item.name) &&
subscribingCommunities[item.name].loading
}
isLoggedIn={isLoggedIn}
/>
)}
/>
</>
);
} else {
return <Text style={[globalStyles.subTitle, styles.noPostTitle]}>{intl.formatMessage({ id: 'profile.havent_posted' })}</Text>;
}
}
return (
<View style={styles.placeholderWrapper}>
<PostCardPlaceHolder />
</View>
);
};
export default withNavigation(TabEmptyView);

View File

@ -0,0 +1,92 @@
import React, {useState, useEffect} from 'react';
import PostsList from '../../postsList';
import { loadPosts } from '../services/tabbedPostsFetch';
import { LoadPostsOptions, TabMeta } from '../services/tabbedPostsModels';
import {useSelector } from 'react-redux';
import TabEmptyView from './listEmptyView';
interface TabContentProps {
filterKey:string,
tabLabel:string,
isFeedScreen:boolean,
promotedPosts:any[],
getFor:string,
pageType:string,
feedUsername:string,
tag:string,
}
const TabContent = ({
filterKey,
isFeedScreen,
promotedPosts,
...props
}: TabContentProps) => {
//redux properties
const isLoggedIn = useSelector((state) => state.application.isLoggedIn);
const isAnalytics = useSelector((state) => state.application.isAnalytics);
const nsfw = useSelector((state) => state.application.nsfw);
const isConnected = useSelector((state) => state.application.isConnected);
//state
const [posts, setPosts] = useState([]);
const [tabMeta, setTabMeta] = useState({
startAuthor:'',
startPermlink:'',
isLoading:false,
isRefreshing:false,
} as TabMeta)
useEffect(()=>{
_loadPosts();
},[])
//load posts implementation
const _loadPosts = async (shouldReset:boolean = false) => {
const options = {
filterKey,
prevPosts:posts,
tabMeta,
setTabMeta,
isLoggedIn,
isAnalytics,
nsfw,
isConnected,
isFeedScreen,
refreshing:shouldReset,
...props
} as LoadPostsOptions
const updatedPosts = await loadPosts(options)
if(updatedPosts){
setPosts(updatedPosts);
}
}
const _renderEmptyContent = () => {
return <TabEmptyView filterKey={filterKey} isNoPost={tabMeta.isNoPost}/>
}
return (
<PostsList
data={posts}
isFeedScreen={isFeedScreen}
promotedPosts={promotedPosts}
onLoadPosts={_loadPosts}
isRefreshing={tabMeta.isRefreshing}
isLoading={tabMeta.isLoading}
ListEmptyComponent={_renderEmptyContent}
/>
);
};
export default TabContent;

View File

@ -0,0 +1,81 @@
import EStyleSheet from 'react-native-extended-stylesheet';
export default EStyleSheet.create({
container: {
backgroundColor: '$primaryLightBackground',
},
placeholder: {
backgroundColor: '$primaryBackgroundColor',
padding: 20,
borderStyle: 'solid',
borderWidth: 1,
borderTopWidth: 1,
borderColor: '#e2e5e8',
borderRadius: 5,
marginRight: 0,
marginLeft: 0,
marginTop: 10,
},
tabs: {
position: 'absolute',
top: '$deviceWidth / 30',
alignItems: 'center',
},
flatlistFooter: {
alignContent: 'center',
alignItems: 'center',
marginTop: 10,
marginBottom: 60,
borderColor: '$borderColor',
},
noImage: {
width: 193,
height: 189,
},
placeholderWrapper: {
flex: 1,
},
noPostTitle: {
textAlign: 'center',
marginVertical: 16,
color: '$primaryBlack',
},
popupContainer: {
position: 'absolute',
top: 80,
left: 0,
right: 0,
alignItems: 'center',
},
popupContentContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '$primaryBlue',
paddingHorizontal: 0,
paddingVertical: 2,
borderRadius: 32,
},
popupText: {
fontWeight: '500',
color: '$white',
marginLeft: 6,
},
closeIcon: {
color: '$white',
margin: 0,
padding: 6,
},
arrowUpIcon: {
color: '$white',
margin: 0,
marginHorizontal: 4,
},
popupImage: {
height: 24,
width: 24,
borderRadius: 12,
borderWidth: 2,
marginLeft: -8,
borderColor: '$primaryBlue',
},
});