feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
122
app/checkin/calendar.tsx
Normal file
122
app/checkin/calendar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { DailyStatusItem, fetchDailyStatusRange } from '@/services/checkins';
|
||||
import { getDailyCheckins, loadMonthCheckins, setCurrentDate } from '@/store/checkinSlice';
|
||||
import { getMonthDaysZh } from '@/utils/date';
|
||||
import dayjs from 'dayjs';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function CheckinCalendarScreen() {
|
||||
const router = useRouter();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [cursor, setCursor] = useState(dayjs());
|
||||
const days = useMemo(() => getMonthDaysZh(cursor), [cursor]);
|
||||
const monthTitle = useMemo(() => `${cursor.format('YYYY年M月')} 打卡`, [cursor]);
|
||||
const [statusMap, setStatusMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadMonthCheckins({ year: cursor.year(), month1Based: cursor.month() + 1 }));
|
||||
const y = cursor.year();
|
||||
const m = cursor.month() + 1;
|
||||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
||||
const startDate = `${y}-${pad(m)}-01`;
|
||||
const endDate = `${y}-${pad(m)}-${pad(new Date(y, m, 0).getDate())}`;
|
||||
fetchDailyStatusRange(startDate, endDate)
|
||||
.then((list: DailyStatusItem[]) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const it of list) {
|
||||
if (typeof it?.date === 'string') next[it.date] = !!it?.checkedIn;
|
||||
}
|
||||
setStatusMap(next);
|
||||
})
|
||||
.catch(() => setStatusMap({}));
|
||||
}, [cursor, dispatch]);
|
||||
|
||||
const goPrevMonth = () => setCursor((c) => c.subtract(1, 'month'));
|
||||
const goNextMonth = () => setCursor((c) => c.add(1, 'month'));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<HeaderBar title="打卡日历" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goPrevMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}>上一月</Text></TouchableOpacity>
|
||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
||||
<TouchableOpacity style={[styles.monthBtn, { backgroundColor: colorTokens.card }]} onPress={goNextMonth}><Text style={[styles.monthBtnText, { color: colorTokens.text }]}>下一月</Text></TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={days}
|
||||
keyExtractor={(item) => item.date.format('YYYY-MM-DD')}
|
||||
numColumns={5}
|
||||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: insets.bottom + 20 }}
|
||||
renderItem={({ item }) => {
|
||||
const d = item.date.toDate();
|
||||
const dateStr = formatDate(d);
|
||||
const hasAny = statusMap[dateStr] ?? !!(checkin?.byDate?.[dateStr]?.items?.length);
|
||||
const isToday = formatDate(new Date()) === dateStr;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
dispatch(setCurrentDate(dateStr));
|
||||
await dispatch(getDailyCheckins(dateStr));
|
||||
router.push('/checkin');
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.dayCell, { backgroundColor: colorTokens.card }, hasAny && styles.dayCellCompleted, isToday && styles.dayCellToday]}
|
||||
>
|
||||
<Text style={[styles.dayNumber, { color: colorTokens.text }]}>{item.dayOfMonth}</Text>
|
||||
{hasAny && <View style={styles.dot} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 6 },
|
||||
monthTitle: { fontSize: 18, fontWeight: '800' },
|
||||
monthBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999 },
|
||||
monthBtnText: { fontWeight: '700' },
|
||||
dayCell: {
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
position: 'relative',
|
||||
},
|
||||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
||||
dayCellToday: { borderWidth: 1, borderColor: '#BBF246' },
|
||||
dayNumber: { fontWeight: '800', fontSize: 16 },
|
||||
dot: { position: 'absolute', top: 6, right: 6, width: 8, height: 8, borderRadius: 4, backgroundColor: '#10B981' },
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user