Files
frontend/src/screens/home/HomeScreen.tsx

837 lines
26 KiB
TypeScript
Raw Normal View History

/**
* HomeScreen
* BBS -
*
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
ScrollView,
StyleSheet,
RefreshControl,
StatusBar,
TouchableOpacity,
NativeSyntheticEvent,
Alert,
Clipboard,
2026-03-18 00:25:46 +08:00
Modal,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { colors, spacing, borderRadius, shadows } from '../../theme';
import { Post } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore';
import { postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
2026-03-16 17:47:10 +08:00
import { SearchScreen } from './SearchScreen';
2026-03-18 00:25:46 +08:00
import { CreatePostScreen } from '../create/CreatePostScreen';
import { navigationService } from '../../infrastructure/navigation/navigationService';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
const TABS = ['推荐', '关注', '热门', '最新'];
const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline'];
const DEFAULT_PAGE_SIZE = 20;
const SWIPE_TRANSLATION_THRESHOLD = 40;
const SWIPE_COOLDOWN_MS = 300;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12;
type ViewMode = 'list' | 'grid';
type PostType = 'recommend' | 'follow' | 'hot' | 'latest';
export const HomeScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore();
const currentUser = useCurrentUser();
// 使用响应式 hook
const {
width,
isMobile,
isTablet,
isDesktop,
isWideScreen,
} = useResponsive();
// 响应式间距
const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const [activeIndex, setActiveIndex] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>('list');
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
2026-03-16 17:47:10 +08:00
// 搜索显示状态(用于内嵌搜索页面)
const [showSearch, setShowSearch] = useState(false);
2026-03-18 00:25:46 +08:00
// 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = useRef<Set<string>>(new Set());
const lastSwipeAtRef = useRef(0);
// 网格模式滚动位置检测
const gridScrollYRef = useRef(0);
const isLoadingMoreRef = useRef(false);
// 构建一个以 postId 为 key 的 map用于快速查找
const postsMap = useMemo(() => {
const map = new Map<string, Post>();
storePosts.forEach(post => {
map.set(post.id, post);
});
return map;
}, [storePosts]);
// 获取当前 tab 对应的帖子类型
const getPostType = useCallback((): PostType => {
switch (activeIndex) {
case 0: return 'recommend';
case 1: return 'follow';
case 2: return 'hot';
case 3: return 'latest';
default: return 'recommend';
}
}, [activeIndex]);
// 使用差异更新 Hook 获取帖子列表
const listKey = useMemo(() => `home_${getPostType()}`, [getPostType]);
const {
posts,
loading: isLoading,
isInitialLoading,
isLoadingMore,
refreshing: isRefreshing,
hasMore,
error,
reset,
} = useDifferentialPosts<Post>(
[],
{
listKey,
enableDiff: true,
enableBatching: true,
autoSubscribe: true,
}
);
// 加载更多方法
const loadMore = useCallback(async () => {
if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) {
isLoadingMoreRef.current = true;
const startedAt = Date.now();
try {
await processPostUseCase.loadMorePosts(listKey);
console.log('[PostPerf] home loadMore', {
listKey,
costMs: Date.now() - startedAt,
totalItems: posts.length,
});
} catch (err) {
console.error('加载更多失败:', err);
} finally {
isLoadingMoreRef.current = false;
}
}
}, [posts.length, listKey, hasMore, isLoadingMore]);
// 网格模式滚动处理 - 检测是否滚动到底部
const handleGridScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const scrollY = contentOffset.y;
const visibleHeight = layoutMeasurement.height;
const contentHeight = contentSize.height;
// 当滚动到距离底部 200px 时触发加载更多
if (scrollY + visibleHeight >= contentHeight - 200) {
loadMore();
}
}, [loadMore]);
// 刷新方法 - 先获取正确类型的帖子,再刷新
const refresh = useCallback(async () => {
try {
// 先获取正确类型的帖子
await processPostUseCase.fetchPosts(
{ page: 1, pageSize: DEFAULT_PAGE_SIZE, post_type: getPostType() },
listKey
);
} catch (err) {
console.error('刷新失败:', err);
}
}, [listKey, getPostType]);
// Tab 切换时刷新数据并重置列表
useEffect(() => {
reset();
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex]);
// 将获取到的原始帖子与 store 中的状态合并
// 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等)
const displayPosts = useMemo(() => {
return posts.map(post => {
const storePost = postsMap.get(post.id);
if (storePost) {
// 如果 store 中有这个帖子,使用 store 的最新状态
return storePost;
}
// 如果 store 中没有这个帖子,使用原始数据
return post;
});
}, [posts, postsMap]);
// 根据屏幕尺寸确定网格列数
const gridColumns = useMemo(() => {
if (isWideScreen || width >= 1440) return 4;
if (isDesktop || width >= 1024) return 3;
if (isTablet || width >= 768) return 2;
return 2; // 移动端瀑布流保持2列
}, [width, isTablet, isDesktop, isWideScreen]);
// 列表模式下始终使用单列,宽屏下居中显示
const useMultiColumnList = useMemo(() => {
return false; // 改为始终返回false使用单列布局
}, []);
// 宽屏下内容最大宽度
const contentMaxWidth = useMemo(() => {
if (isWideScreen) return 800;
if (isDesktop) return 720;
if (isTablet) return 640;
return width; // 移动端使用全宽
}, [width, isTablet, isDesktop, isWideScreen]);
// 列表模式横向内边距:移动端适当收窄,减少两侧空白
const listHorizontalPadding = useMemo(() => {
if (isMobile) {
return Math.max(6, responsivePadding - responsiveGap);
}
return responsivePadding;
}, [isMobile, responsivePadding, responsiveGap]);
// 列表模式卡片宽度:宽屏限宽并居中,移动端占用更多可用宽度
const listItemWidth = useMemo(() => {
const availableWidth = Math.max(0, width - listHorizontalPadding * 2);
if (isDesktop || isWideScreen) {
return Math.min(contentMaxWidth, availableWidth);
}
return availableWidth;
}, [width, listHorizontalPadding, isDesktop, isWideScreen, contentMaxWidth]);
const floatingButtonBottom = useMemo(() => {
if (!isMobile) {
return undefined;
}
// TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方
// TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16
return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom;
}, [isMobile, insets.bottom]);
// 切换视图模式
const toggleViewMode = () => {
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
};
// 切换Tab手势/点击共用)
const changeTab = useCallback((nextIndex: number) => {
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
return;
}
setActiveIndex(nextIndex);
}, [activeIndex]);
const handleSwipeTabChange = useCallback((translationX: number) => {
setActiveIndex(prev => (
translationX < 0
? Math.min(prev + 1, TABS.length - 1)
: Math.max(prev - 1, 0)
));
}, []);
const swipeGesture = useMemo(() => (
Gesture.Pan()
.runOnJS(true)
.activeOffsetX([-15, 15])
.failOffsetY([-20, 20])
.shouldCancelWhenOutside(true)
.onEnd((event) => {
const now = Date.now();
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
return;
}
if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
return;
}
lastSwipeAtRef.current = now;
handleSwipeTabChange(event.translationX);
})
), [handleSwipeTabChange]);
2026-03-16 17:47:10 +08:00
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => {
2026-03-16 17:47:10 +08:00
setShowSearch(true);
};
// 跳转到帖子详情
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
navigationService.navigate('PostDetail', { postId, scrollToComments });
};
// 跳转到用户主页
const handleUserPress = (userId: string) => {
navigationService.navigate('UserProfile', { userId });
};
// 点赞帖子
const handleLike = async (post: Post) => {
try {
if (post.is_liked) {
await processPostUseCase.unlikePost(post.id);
} else {
await processPostUseCase.likePost(post.id);
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
// 收藏帖子
const handleBookmark = async (post: Post) => {
try {
if (post.is_favorited) {
await processPostUseCase.unfavoritePost(post.id);
} else {
await processPostUseCase.favoritePost(post.id);
}
} catch (error) {
console.error('收藏操作失败:', error);
}
};
// 分享帖子
const handleShare = async (post: Post) => {
if (!post?.id) return;
try {
await postService.sharePost(post.id);
} catch (shareError) {
console.error('上报分享次数失败:', shareError);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
};
// 删除帖子 - 删除后刷新列表
const handleDeletePost = async (postId: string) => {
try {
const success = await postService.deletePost(postId);
if (success) {
// 刷新列表以移除已删除的帖子
refresh();
} else {
console.error('删除帖子失败');
}
} catch (deleteError) {
console.error('删除帖子失败:', deleteError);
throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示
}
};
// 处理图片点击 - 打开图片查看器
const handleImagePress = (images: ImageGridItem[], index: number) => {
setPostImages(images);
setSelectedImageIndex(index);
setShowImageViewer(true);
};
// 统一处理 PostCard 的所有操作
const handlePostAction = (post: Post, action: PostCardAction) => {
switch (action.type) {
case 'press':
handlePostPress(post.id);
break;
case 'userPress':
const authorId = post.author?.id;
if (authorId) handleUserPress(authorId);
break;
case 'like':
case 'unlike':
handleLike(post);
break;
case 'comment':
handlePostPress(post.id, true);
break;
case 'bookmark':
case 'unbookmark':
handleBookmark(post);
break;
case 'share':
handleShare(post);
break;
case 'imagePress':
if (action.payload?.images && action.payload?.imageIndex !== undefined) {
handleImagePress(action.payload.images, action.payload.imageIndex);
}
break;
case 'delete':
handleDeletePost(post.id);
break;
}
};
2026-03-18 00:25:46 +08:00
// 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => {
2026-03-18 00:25:46 +08:00
setShowCreatePost(true);
};
// 渲染帖子卡片(列表模式)
const keyExtractor = useCallback((item: Post) => item.id, []);
const renderPostList = useCallback(({ item }: { item: Post }) => {
const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View style={[
styles.listItem,
{
marginBottom: responsiveGap,
width: listItemWidth,
alignSelf: 'center',
borderRadius: isMobile ? borderRadius.lg : 0,
overflow: isMobile ? 'hidden' : 'visible',
}
]}>
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]);
// 估算帖子在瀑布流中的高度(用于均匀分配)
const estimatePostHeight = (post: Post, columnWidth: number): number => {
const hasImage = post.images && post.images.length > 0;
const hasTitle = !!post.title;
const hasContent = !!post.content;
let height = 0;
// 图片区域高度(如果有图)
if (hasImage) {
// 使用帖子 ID 生成一致的宽高比
const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
const aspectRatio = aspectRatios[hash % aspectRatios.length];
height += columnWidth / aspectRatio;
} else {
// 无图帖子显示正文预览区域
if (hasContent) {
// 根据内容长度估算高度每行约20像素最多6行
const contentLength = post.content?.length || 0;
const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20)));
height += 16 + estimatedLines * 20; // padding + 文本高度
}
}
// 标题高度
if (hasTitle) {
const titleLines = hasImage ? 2 : 3;
height += 8 + titleLines * 20; // paddingTop + 文本高度
}
// 底部信息栏高度
height += 40; // 用户信息 + 点赞数
// 间距
height += 2; // marginBottom
return height;
};
// 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀
const distributePostsToColumns = useMemo(() => {
const columns: Post[][] = Array.from({ length: gridColumns }, () => []);
const columnHeights: number[] = Array(gridColumns).fill(0);
// 防御性检查:确保 posts 存在且是数组
if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) {
return columns;
}
// 计算单列宽度
const totalGap = (gridColumns - 1) * responsiveGap;
const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns;
displayPosts.forEach((post) => {
const postHeight = estimatePostHeight(post, columnWidth);
// 找到当前高度最小的列
const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights));
columns[minHeightIndex].push(post);
columnHeights[minHeightIndex] += postHeight;
});
return columns;
}, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]);
// 渲染单列帖子
const renderWaterfallColumn = (column: Post[], columnIndex: number) => (
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
{column.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
<PostCard
post={post}
variant="grid"
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
// 渲染响应式网格布局(平板/桌面端使用多列)
const renderResponsiveGrid = () => {
if (isMobile && !isTablet) {
// 移动端使用瀑布流布局2列
return (
<ScrollView
style={styles.waterfallScroll}
contentContainerStyle={[
styles.waterfallContainer,
{
paddingHorizontal: responsivePadding,
paddingBottom: 80 + responsivePadding,
}
]}
showsVerticalScrollIndicator={false}
scrollEventThrottle={100}
onScroll={handleGridScroll}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
{/* 加载更多指示器 */}
{isLoading && displayPosts.length > 0 && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 平板/桌面端使用 ResponsiveGrid 组件
return (
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
>
{displayPosts.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<PostCard
key={post.id}
post={post}
variant={viewMode === 'grid' ? 'grid' : 'list'}
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
);
})}
</ResponsiveGrid>
);
};
// 渲染空状态
const renderEmpty = useCallback(() => {
if (isInitialLoading) return null;
return (
<EmptyState
title="暂无内容"
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
/>
);
}, [activeIndex, isInitialLoading]);
// 渲染列表内容
const renderListContent = () => {
if (useMultiColumnList) {
// 平板/桌面端使用多列网格
return renderResponsiveGrid();
}
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return (
<FlatList
data={displayPosts}
renderItem={renderPostList}
keyExtractor={keyExtractor}
contentContainerStyle={[
styles.listContent,
{
paddingHorizontal: listHorizontalPadding,
paddingBottom: 80 + responsivePadding,
alignItems: 'center', // 确保子项居中
}
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
initialNumToRender={8}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews
/>
);
};
2026-03-16 17:47:10 +08:00
// 搜索页面内嵌模式
if (showSearch) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
2026-03-16 17:47:10 +08:00
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
<SearchScreen
onBack={() => setShowSearch(false)}
/>
</SafeAreaView>
);
}
// 正常首页内容
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
{/* 顶部Header */}
<View style={styles.header}>
{/* 搜索栏 */}
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={handleSearchPress}
onFocus={handleSearchPress}
placeholder="搜索帖子、用户"
/>
</View>
{/* Tab切换 */}
<TabBar
tabs={TABS}
icons={TAB_ICONS}
activeIndex={activeIndex}
onTabChange={changeTab}
variant="modern"
rightContent={
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
<MaterialCommunityIcons
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
size={22}
color="#666"
/>
</TouchableOpacity>
}
/>
</View>
{/* 帖子列表 */}
2026-03-16 17:47:10 +08:00
{isMobile ? (
// 移动端:使用 GestureDetector 支持手势切换 Tab
<GestureDetector gesture={swipeGesture}>
<View style={styles.contentContainer}>
{isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
2026-03-16 17:47:10 +08:00
renderListContent()
) : (
// 网格模式:使用响应式网格
renderResponsiveGrid()
)}
</View>
</GestureDetector>
) : (
// 桌面端/平板端:不使用 GestureDetector避免拦截侧边栏点击
<View style={styles.contentContainer}>
{isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
renderListContent()
) : (
// 网格模式:使用响应式网格
renderResponsiveGrid()
)}
</View>
2026-03-16 17:47:10 +08:00
)}
{/* 漂浮发帖按钮 */}
<TouchableOpacity
style={[
styles.floatingButton,
isDesktop && styles.floatingButtonDesktop,
isWideScreen && styles.floatingButtonWide,
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]}
onPress={handleCreatePost}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
</TouchableOpacity>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
2026-03-16 17:47:10 +08:00
images={postImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)}
enableSave
/>
2026-03-18 00:25:46 +08:00
{/* 发帖弹窗 */}
<Modal
visible={showCreatePost}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowCreatePost(false)}
>
<CreatePostScreen
onClose={() => setShowCreatePost(false)}
/>
</Modal>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingBottom: spacing.sm,
2026-03-16 17:47:10 +08:00
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
viewToggleBtn: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
listContent: {
flexGrow: 1,
},
contentContainer: {
flex: 1,
},
listItem: {
// 动态设置 marginBottom
},
waterfallScroll: {
flex: 1,
},
waterfallContainer: {
flexDirection: 'row',
flexGrow: 1,
alignItems: 'flex-start',
},
waterfallColumn: {
flex: 1,
},
waterfallItem: {
// 动态设置 marginBottom
},
floatingButton: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
...shadows.lg,
},
floatingButtonDesktop: {
right: 40,
bottom: 40,
width: 64,
height: 64,
borderRadius: 32,
},
floatingButtonWide: {
right: 60,
bottom: 60,
width: 72,
height: 72,
borderRadius: 36,
},
loadingMore: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
});