- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测 - 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序 - 重构首页统计页面布局逻辑,支持动态渲染与混合布局 - 引入 react-native-draggable-flatlist 用于实现拖拽排序功能 - 添加相关多语言配置及用户偏好设置存储接口
800 lines
21 KiB
TypeScript
800 lines
21 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import dayjs from 'dayjs';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { Stack, useRouter } from 'expo-router';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
DimensionValue,
|
||
FlatList,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
import { Colors } from '@/constants/Colors';
|
||
import {
|
||
CycleRecord,
|
||
DEFAULT_PERIOD_LENGTH,
|
||
MenstrualDayCell,
|
||
MenstrualDayStatus,
|
||
MenstrualTimeline,
|
||
buildMenstrualTimeline
|
||
} from '@/utils/menstrualCycle';
|
||
|
||
type TabKey = 'cycle' | 'analysis';
|
||
|
||
const ITEM_HEIGHT = 380;
|
||
const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
|
||
period: { bg: '#f5679f', text: '#fff' },
|
||
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
|
||
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
|
||
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
|
||
};
|
||
|
||
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
|
||
|
||
const chunkArray = <T,>(array: T[], size: number): T[][] => {
|
||
const result: T[][] = [];
|
||
for (let i = 0; i < array.length; i += size) {
|
||
result.push(array.slice(i, i + size));
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const DayCell = ({
|
||
cell,
|
||
isSelected,
|
||
onPress,
|
||
}: {
|
||
cell: Extract<MenstrualDayCell, { type: 'day' }>;
|
||
isSelected: boolean;
|
||
onPress: () => void;
|
||
}) => {
|
||
const status = cell.info?.status;
|
||
const colors = status ? STATUS_COLORS[status] : undefined;
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
activeOpacity={0.8}
|
||
style={styles.dayCell}
|
||
onPress={onPress}
|
||
>
|
||
<View
|
||
style={[
|
||
styles.dayCircle,
|
||
colors && { backgroundColor: colors.bg },
|
||
isSelected && styles.dayCircleSelected,
|
||
cell.isToday && styles.todayOutline,
|
||
]}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.dayLabel,
|
||
colors && { color: colors.text },
|
||
!colors && styles.dayLabelDefault,
|
||
]}
|
||
>
|
||
{cell.label}
|
||
</Text>
|
||
</View>
|
||
{cell.isToday && <Text style={styles.todayText}>今天</Text>}
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
const MonthBlock = ({
|
||
month,
|
||
selectedDateKey,
|
||
onSelect,
|
||
renderTip,
|
||
}: {
|
||
month: MenstrualTimeline['months'][number];
|
||
selectedDateKey: string;
|
||
onSelect: (dateKey: string) => void;
|
||
renderTip: (colIndex: number) => React.ReactNode;
|
||
}) => {
|
||
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
|
||
|
||
return (
|
||
<View style={styles.monthCard}>
|
||
<View style={styles.monthHeader}>
|
||
<Text style={styles.monthTitle}>{month.title}</Text>
|
||
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
|
||
</View>
|
||
<View style={styles.weekRow}>
|
||
{WEEK_LABELS.map((label) => (
|
||
<Text key={label} style={styles.weekLabel}>
|
||
{label}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
<View style={styles.monthGrid}>
|
||
{weeks.map((week, weekIndex) => {
|
||
const selectedIndex = week.findIndex(
|
||
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
|
||
);
|
||
|
||
return (
|
||
<React.Fragment key={weekIndex}>
|
||
<View style={styles.daysRow}>
|
||
{week.map((cell) => {
|
||
if (cell.type === 'placeholder') {
|
||
return <View key={cell.key} style={styles.dayCell} />;
|
||
}
|
||
const dateKey = cell.date.format('YYYY-MM-DD');
|
||
return (
|
||
<DayCell
|
||
key={cell.key}
|
||
cell={cell}
|
||
isSelected={selectedDateKey === dateKey}
|
||
onPress={() => onSelect(dateKey)}
|
||
/>
|
||
);
|
||
})}
|
||
</View>
|
||
{selectedIndex !== -1 && (
|
||
<View style={styles.inlineTipContainer}>
|
||
{renderTip(selectedIndex)}
|
||
</View>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default function MenstrualCycleScreen() {
|
||
const router = useRouter();
|
||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
||
const timeline = useMemo(
|
||
() =>
|
||
buildMenstrualTimeline({
|
||
monthsBefore: windowConfig.before,
|
||
monthsAfter: windowConfig.after,
|
||
records,
|
||
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||
}),
|
||
[records, windowConfig]
|
||
);
|
||
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
|
||
const [selectedDateKey, setSelectedDateKey] = useState(
|
||
dayjs().format('YYYY-MM-DD')
|
||
);
|
||
const listRef = useRef<FlatList>(null);
|
||
const offsetRef = useRef(0);
|
||
const prependDeltaRef = useRef(0);
|
||
const loadingPrevRef = useRef(false);
|
||
|
||
const selectedInfo = timeline.dayMap[selectedDateKey];
|
||
const selectedDate = dayjs(selectedDateKey);
|
||
|
||
|
||
const handleMarkStart = () => {
|
||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||
|
||
// Check if the selected date is already covered by an existing record (including duration)
|
||
const isCovered = records.some((r) => {
|
||
const start = dayjs(r.startDate);
|
||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||
return (
|
||
(selectedDate.isSame(start, 'day') || selectedDate.isAfter(start, 'day')) &&
|
||
(selectedDate.isSame(end, 'day') || selectedDate.isBefore(end, 'day'))
|
||
);
|
||
});
|
||
if (isCovered) return;
|
||
|
||
setRecords((prev) => {
|
||
const updated = [...prev];
|
||
|
||
// 1. Check if selectedDate is immediately after an existing period
|
||
const prevRecordIndex = updated.findIndex((r) => {
|
||
const start = dayjs(r.startDate);
|
||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||
return end.add(1, 'day').isSame(selectedDate, 'day');
|
||
});
|
||
|
||
// 2. Check if selectedDate is immediately before an existing period
|
||
const nextRecordIndex = updated.findIndex((r) => {
|
||
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
||
});
|
||
|
||
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
||
// Merge three parts: Prev + Selected + Next
|
||
const prevRecord = updated[prevRecordIndex];
|
||
const nextRecord = updated[nextRecordIndex];
|
||
const newLength =
|
||
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
|
||
1 +
|
||
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
|
||
|
||
updated[prevRecordIndex] = {
|
||
...prevRecord,
|
||
periodLength: newLength,
|
||
};
|
||
// Remove the next record since it's merged
|
||
updated.splice(nextRecordIndex, 1);
|
||
} else if (prevRecordIndex !== -1) {
|
||
// Extend previous record
|
||
const prevRecord = updated[prevRecordIndex];
|
||
updated[prevRecordIndex] = {
|
||
...prevRecord,
|
||
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||
};
|
||
} else if (nextRecordIndex !== -1) {
|
||
// Extend next record (start earlier)
|
||
const nextRecord = updated[nextRecordIndex];
|
||
updated[nextRecordIndex] = {
|
||
...nextRecord,
|
||
startDate: selectedDate.format('YYYY-MM-DD'),
|
||
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||
};
|
||
} else {
|
||
// Create new isolated record
|
||
const newRecord: CycleRecord = {
|
||
startDate: selectedDate.format('YYYY-MM-DD'),
|
||
periodLength: 7,
|
||
source: 'manual',
|
||
};
|
||
updated.push(newRecord);
|
||
}
|
||
|
||
return updated.sort(
|
||
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
|
||
);
|
||
});
|
||
};
|
||
|
||
const handleCancelMark = () => {
|
||
if (!selectedInfo || !selectedInfo.confirmed) return;
|
||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||
const target = selectedDate;
|
||
|
||
setRecords((prev) => {
|
||
const updated: CycleRecord[] = [];
|
||
prev.forEach((record) => {
|
||
const start = dayjs(record.startDate);
|
||
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||
const diff = target.diff(start, 'day');
|
||
|
||
if (diff < 0 || diff >= periodLength) {
|
||
updated.push(record);
|
||
return;
|
||
}
|
||
|
||
if (diff === 0) {
|
||
// 取消开始日:移除整段记录
|
||
return;
|
||
}
|
||
|
||
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
|
||
updated.push({
|
||
...record,
|
||
periodLength: diff,
|
||
});
|
||
});
|
||
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const handleLoadPrevious = () => {
|
||
if (loadingPrevRef.current) return;
|
||
loadingPrevRef.current = true;
|
||
const delta = 3;
|
||
prependDeltaRef.current = delta;
|
||
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (prependDeltaRef.current > 0 && listRef.current) {
|
||
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
|
||
requestAnimationFrame(() => {
|
||
listRef.current?.scrollToOffset({ offset, animated: false });
|
||
prependDeltaRef.current = 0;
|
||
loadingPrevRef.current = false;
|
||
});
|
||
}
|
||
}, [timeline.months.length]);
|
||
|
||
const viewabilityConfig = useRef({
|
||
viewAreaCoveragePercentThreshold: 10,
|
||
}).current;
|
||
|
||
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
|
||
const minIndex = viewableItems.reduce(
|
||
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
|
||
Number.MAX_SAFE_INTEGER
|
||
);
|
||
if (minIndex <= 1) {
|
||
handleLoadPrevious();
|
||
}
|
||
}).current;
|
||
|
||
const renderLegend = () => (
|
||
<View style={styles.legendRow}>
|
||
{[
|
||
{ label: '经期', key: 'period' as const },
|
||
{ label: '预测经期', key: 'predicted-period' as const },
|
||
{ label: '排卵期', key: 'fertile' as const },
|
||
{ label: '排卵日', key: 'ovulation-day' as const },
|
||
].map((item) => (
|
||
<View key={item.key} style={styles.legendItem}>
|
||
<View
|
||
style={[
|
||
styles.legendDot,
|
||
{ backgroundColor: STATUS_COLORS[item.key].bg },
|
||
item.key === 'ovulation-day' && styles.legendDotRing,
|
||
]}
|
||
/>
|
||
<Text style={styles.legendLabel}>{item.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
);
|
||
|
||
const listData = useMemo(() => {
|
||
return timeline.months.map((m) => ({
|
||
type: 'month' as const,
|
||
id: m.id,
|
||
month: m,
|
||
}));
|
||
}, [timeline.months]);
|
||
|
||
const renderInlineTip = (columnIndex: number) => {
|
||
// 14.28% per cell. Center is 7.14%.
|
||
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
|
||
const isFuture = selectedDate.isAfter(dayjs(), 'day');
|
||
|
||
const base = (
|
||
<View style={styles.inlineTipCard}>
|
||
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
|
||
<View style={styles.inlineTipRow}>
|
||
<View style={styles.inlineTipDate}>
|
||
<Ionicons name="calendar-outline" size={16} color="#111827" />
|
||
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
|
||
</View>
|
||
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
|
||
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={handleMarkStart}>
|
||
<Ionicons name="add" size={14} color="#fff" />
|
||
<Text style={styles.inlinePrimaryText}>标记经期</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
|
||
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={handleCancelMark}>
|
||
<Text style={styles.inlineSecondaryText}>取消标记</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
return base;
|
||
};
|
||
|
||
const renderCycleTab = () => (
|
||
<View style={styles.tabContent}>
|
||
{renderLegend()}
|
||
|
||
|
||
<FlatList
|
||
ref={listRef}
|
||
data={listData}
|
||
keyExtractor={(item) => item.id}
|
||
renderItem={({ item }) => (
|
||
<MonthBlock
|
||
month={item.month}
|
||
selectedDateKey={selectedDateKey}
|
||
onSelect={(key) => setSelectedDateKey(key)}
|
||
renderTip={renderInlineTip}
|
||
/>
|
||
)}
|
||
showsVerticalScrollIndicator={false}
|
||
initialNumToRender={3}
|
||
windowSize={5}
|
||
maxToRenderPerBatch={4}
|
||
removeClippedSubviews
|
||
contentContainerStyle={styles.listContent}
|
||
viewabilityConfig={viewabilityConfig}
|
||
onViewableItemsChanged={onViewableItemsChanged}
|
||
onScroll={(e) => {
|
||
offsetRef.current = e.nativeEvent.contentOffset.y;
|
||
}}
|
||
scrollEventThrottle={16}
|
||
/>
|
||
</View>
|
||
);
|
||
|
||
const renderAnalysisTab = () => (
|
||
<View style={styles.tabContent}>
|
||
<View style={styles.analysisCard}>
|
||
<Text style={styles.analysisTitle}>分析</Text>
|
||
<Text style={styles.analysisBody}>
|
||
基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<Stack.Screen options={{ headerShown: false }} />
|
||
<LinearGradient
|
||
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
|
||
style={StyleSheet.absoluteFill}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 0, y: 1 }}
|
||
/>
|
||
|
||
<View style={styles.header}>
|
||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||
</TouchableOpacity>
|
||
<Text style={styles.headerTitle}>生理周期</Text>
|
||
<TouchableOpacity style={styles.headerIcon}>
|
||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<View style={styles.tabSwitcher}>
|
||
{([
|
||
{ key: 'cycle', label: '生理周期' },
|
||
{ key: 'analysis', label: '分析' },
|
||
] as { key: TabKey; label: string }[]).map((tab) => {
|
||
const active = activeTab === tab.key;
|
||
return (
|
||
<TouchableOpacity
|
||
key={tab.key}
|
||
style={[styles.tabPill, active && styles.tabPillActive]}
|
||
onPress={() => setActiveTab(tab.key)}
|
||
activeOpacity={0.9}
|
||
>
|
||
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
|
||
{tab.label}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
|
||
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
paddingTop: 52,
|
||
paddingHorizontal: 16,
|
||
backgroundColor: 'transparent',
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 12,
|
||
},
|
||
headerIcon: {
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||
},
|
||
headerTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '800',
|
||
color: '#0f172a',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
tabSwitcher: {
|
||
flexDirection: 'row',
|
||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||
borderRadius: 18,
|
||
padding: 4,
|
||
marginBottom: 16,
|
||
},
|
||
tabPill: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
paddingVertical: 10,
|
||
borderRadius: 14,
|
||
},
|
||
tabPillActive: {
|
||
backgroundColor: '#fff',
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.08,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
shadowRadius: 10,
|
||
elevation: 3,
|
||
},
|
||
tabLabel: {
|
||
color: '#4b5563',
|
||
fontWeight: '600',
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
tabLabelActive: {
|
||
color: '#0f172a',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
tabContent: {
|
||
flex: 1,
|
||
},
|
||
legendRow: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 12,
|
||
marginBottom: 12,
|
||
paddingHorizontal: 4,
|
||
},
|
||
legendItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
legendDot: {
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: 8,
|
||
marginRight: 6,
|
||
},
|
||
legendDotRing: {
|
||
borderWidth: 2,
|
||
borderColor: '#fff',
|
||
},
|
||
legendLabel: {
|
||
fontSize: 13,
|
||
color: '#111827',
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
selectedCard: {
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 12,
|
||
marginBottom: 10,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 10,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
elevation: 3,
|
||
},
|
||
selectedStatus: {
|
||
fontSize: 14,
|
||
color: '#111827',
|
||
fontWeight: '700',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
tipCard: {
|
||
backgroundColor: '#f4f3ff',
|
||
borderRadius: 14,
|
||
padding: 12,
|
||
marginTop: 10,
|
||
borderWidth: 1,
|
||
borderColor: '#ede9fe',
|
||
},
|
||
tipTitle: {
|
||
fontSize: 14,
|
||
color: '#111827',
|
||
fontWeight: '700',
|
||
marginBottom: 4,
|
||
fontFamily: 'AliBold',
|
||
},
|
||
tipDesc: {
|
||
fontSize: 12,
|
||
color: '#6b7280',
|
||
lineHeight: 18,
|
||
marginBottom: 8,
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
tipButton: {
|
||
backgroundColor: Colors.light.primary,
|
||
paddingVertical: 10,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
},
|
||
tipButtonText: {
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
tipSecondaryButton: {
|
||
backgroundColor: '#fff',
|
||
paddingVertical: 10,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
borderWidth: 1,
|
||
borderColor: '#e5e7eb',
|
||
},
|
||
tipSecondaryButtonText: {
|
||
color: '#0f172a',
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
inlineTipCard: {
|
||
backgroundColor: '#e8e7ff',
|
||
borderRadius: 18,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 12,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.04,
|
||
shadowRadius: 6,
|
||
shadowOffset: { width: 0, height: 2 },
|
||
elevation: 1,
|
||
},
|
||
inlineTipPointer: {
|
||
position: 'absolute',
|
||
top: -6,
|
||
width: 12,
|
||
height: 12,
|
||
marginLeft: -6,
|
||
backgroundColor: '#e8e7ff',
|
||
transform: [{ rotate: '45deg' }],
|
||
borderRadius: 3,
|
||
},
|
||
inlineTipRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 8,
|
||
},
|
||
inlineTipDate: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
inlineTipDateText: {
|
||
fontSize: 14,
|
||
color: '#111827',
|
||
fontWeight: '800',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
inlinePrimaryBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: Colors.light.primary,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 14,
|
||
gap: 6,
|
||
},
|
||
inlinePrimaryText: {
|
||
color: '#fff',
|
||
fontSize: 13,
|
||
fontWeight: '700',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
inlineSecondaryBtn: {
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 14,
|
||
backgroundColor: '#fff',
|
||
borderWidth: 1,
|
||
borderColor: '#d1d5db',
|
||
},
|
||
inlineSecondaryText: {
|
||
color: '#111827',
|
||
fontSize: 13,
|
||
fontWeight: '700',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
monthCard: {
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
padding: 14,
|
||
marginBottom: 12,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 6 },
|
||
elevation: 2,
|
||
},
|
||
monthHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 8,
|
||
},
|
||
monthTitle: {
|
||
fontSize: 17,
|
||
fontWeight: '800',
|
||
color: '#0f172a',
|
||
fontFamily: 'AliBold',
|
||
},
|
||
monthSubtitle: {
|
||
fontSize: 12,
|
||
color: '#6b7280',
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
weekRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 6,
|
||
paddingHorizontal: 4,
|
||
},
|
||
weekLabel: {
|
||
width: '14.28%',
|
||
textAlign: 'center',
|
||
fontSize: 12,
|
||
color: '#94a3b8',
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
monthGrid: {
|
||
flexDirection: 'column',
|
||
},
|
||
daysRow: {
|
||
flexDirection: 'row',
|
||
},
|
||
dayCell: {
|
||
width: '14.28%',
|
||
alignItems: 'center',
|
||
marginVertical: 6,
|
||
},
|
||
inlineTipContainer: {
|
||
paddingBottom: 6,
|
||
marginBottom: 6,
|
||
},
|
||
dayCircle: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#f3f4f6',
|
||
},
|
||
dayCircleSelected: {
|
||
borderWidth: 2,
|
||
borderColor: Colors.light.primary,
|
||
},
|
||
todayOutline: {
|
||
borderWidth: 2,
|
||
borderColor: '#94a3b8',
|
||
},
|
||
dayLabel: {
|
||
fontSize: 15,
|
||
fontFamily: 'AliBold',
|
||
},
|
||
dayLabelDefault: {
|
||
color: '#111827',
|
||
},
|
||
todayText: {
|
||
fontSize: 10,
|
||
color: '#9ca3af',
|
||
marginTop: 2,
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
listContent: {
|
||
paddingBottom: 80,
|
||
},
|
||
analysisCard: {
|
||
backgroundColor: '#fff',
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
marginTop: 8,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 10,
|
||
shadowOffset: { width: 0, height: 6 },
|
||
elevation: 3,
|
||
},
|
||
analysisTitle: {
|
||
fontSize: 17,
|
||
fontWeight: '800',
|
||
color: '#0f172a',
|
||
marginBottom: 8,
|
||
fontFamily: 'AliBold',
|
||
},
|
||
analysisBody: {
|
||
fontSize: 14,
|
||
color: '#6b7280',
|
||
lineHeight: 20,
|
||
fontFamily: 'AliRegular',
|
||
},
|
||
});
|