feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
@@ -4,152 +4,51 @@ 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 { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||
import {
|
||||
deleteMenstrualFlow,
|
||||
fetchMenstrualFlowSamples,
|
||||
saveMenstrualFlow
|
||||
} from '@/utils/health';
|
||||
import {
|
||||
buildMenstrualTimeline,
|
||||
convertHealthKitSamplesToCycleRecords,
|
||||
CycleRecord,
|
||||
DEFAULT_PERIOD_LENGTH,
|
||||
MenstrualDayCell,
|
||||
MenstrualDayStatus,
|
||||
MenstrualTimeline,
|
||||
buildMenstrualTimeline
|
||||
DEFAULT_PERIOD_LENGTH
|
||||
} 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 { t } = useTranslation();
|
||||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
||||
|
||||
// Load data from HealthKit
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// Calculate date range based on windowConfig
|
||||
const today = dayjs();
|
||||
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
|
||||
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
|
||||
|
||||
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
|
||||
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
|
||||
setRecords(convertedRecords);
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [windowConfig]);
|
||||
|
||||
const timeline = useMemo(
|
||||
() =>
|
||||
buildMenstrualTimeline({
|
||||
@@ -173,10 +72,10 @@ export default function MenstrualCycleScreen() {
|
||||
const selectedDate = dayjs(selectedDateKey);
|
||||
|
||||
|
||||
const handleMarkStart = () => {
|
||||
const handleMarkStart = async () => {
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
|
||||
// Check if the selected date is already covered by an existing record (including duration)
|
||||
// Check if the selected date is already covered
|
||||
const isCovered = records.some((r) => {
|
||||
const start = dayjs(r.startDate);
|
||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||
@@ -187,45 +86,36 @@ export default function MenstrualCycleScreen() {
|
||||
});
|
||||
if (isCovered) return;
|
||||
|
||||
// Optimistic Update
|
||||
const originalRecords = [...records];
|
||||
setRecords((prev) => {
|
||||
const updated = [...prev];
|
||||
|
||||
// 1. Check if selectedDate is immediately after an existing period
|
||||
// Logic for optimistic UI update (same as original logic)
|
||||
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[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
|
||||
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,
|
||||
@@ -233,7 +123,6 @@ export default function MenstrualCycleScreen() {
|
||||
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||
};
|
||||
} else {
|
||||
// Create new isolated record
|
||||
const newRecord: CycleRecord = {
|
||||
startDate: selectedDate.format('YYYY-MM-DD'),
|
||||
periodLength: 7,
|
||||
@@ -241,18 +130,59 @@ export default function MenstrualCycleScreen() {
|
||||
};
|
||||
updated.push(newRecord);
|
||||
}
|
||||
|
||||
return updated.sort(
|
||||
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
|
||||
);
|
||||
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine what to save to HealthKit
|
||||
// If we are merging or extending, we are effectively adding one day of flow
|
||||
// If we are creating a new record, we default to 7 days
|
||||
// However, accurate HealthKit logging should be per day.
|
||||
// The previous UI logic "creates" a 7-day period for a single tap.
|
||||
// We should replicate this behavior in HealthKit for consistency.
|
||||
|
||||
const isNewIsolatedRecord = !records.some((r) => {
|
||||
const start = dayjs(r.startDate);
|
||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||
// Check adjacency
|
||||
return (
|
||||
end.add(1, 'day').isSame(selectedDate, 'day') ||
|
||||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
|
||||
);
|
||||
});
|
||||
|
||||
if (isNewIsolatedRecord) {
|
||||
// Save 7 days of flow starting from selectedDate
|
||||
const promises = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = selectedDate.add(i, 'day');
|
||||
// Don't save future dates if they exceed today (though logic allows predicting)
|
||||
// But for flow logging, we usually only log past/present.
|
||||
// However, UI allows setting a period that might extend slightly?
|
||||
// Let's stick to the selected date logic.
|
||||
// Wait, if I tap "Mark Start", it creates a 7 day period.
|
||||
// Should I write 7 samples? Yes, to match the UI state.
|
||||
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
|
||||
}
|
||||
await Promise.all(promises);
|
||||
} else {
|
||||
// Just adding a single day to bridge/extend
|
||||
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save to HealthKit', error);
|
||||
// Revert optimistic update
|
||||
setRecords(originalRecords);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMark = () => {
|
||||
const handleCancelMark = async () => {
|
||||
if (!selectedInfo || !selectedInfo.confirmed) return;
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
const target = selectedDate;
|
||||
|
||||
// Optimistic Update
|
||||
const originalRecords = [...records];
|
||||
setRecords((prev) => {
|
||||
const updated: CycleRecord[] = [];
|
||||
prev.forEach((record) => {
|
||||
@@ -264,21 +194,47 @@ export default function MenstrualCycleScreen() {
|
||||
updated.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff === 0) {
|
||||
// 取消开始日:移除整段记录
|
||||
return;
|
||||
}
|
||||
|
||||
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
|
||||
updated.push({
|
||||
...record,
|
||||
periodLength: diff,
|
||||
});
|
||||
if (diff === 0) return; // Remove entire record (or start of it)
|
||||
updated.push({ ...record, periodLength: diff }); // Shorten it
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
try {
|
||||
// Logic:
|
||||
// 1. Find the record covering the target date
|
||||
const record = records.find((r) => {
|
||||
const start = dayjs(r.startDate);
|
||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||
return (
|
||||
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
|
||||
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
|
||||
);
|
||||
});
|
||||
|
||||
if (record) {
|
||||
const start = dayjs(record.startDate);
|
||||
const diff = target.diff(start, 'day');
|
||||
|
||||
if (diff === 0) {
|
||||
// If cancelling the start date, the UI removes the ENTIRE period record.
|
||||
// So we should delete all samples for this period range.
|
||||
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||||
const endDate = start.add(periodLength - 1, 'day');
|
||||
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
|
||||
} else {
|
||||
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
|
||||
// So we delete from target date onwards to the original end date.
|
||||
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||||
const originalEnd = start.add(periodLength - 1, 'day');
|
||||
// Delete from target to originalEnd
|
||||
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete from HealthKit', error);
|
||||
setRecords(originalRecords);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadPrevious = () => {
|
||||
@@ -314,27 +270,7 @@ export default function MenstrualCycleScreen() {
|
||||
}
|
||||
}).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) => ({
|
||||
@@ -344,40 +280,19 @@ export default function MenstrualCycleScreen() {
|
||||
}));
|
||||
}, [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 renderInlineTip = (columnIndex: number) => (
|
||||
<InlineTip
|
||||
selectedDate={selectedDate}
|
||||
selectedInfo={selectedInfo}
|
||||
columnIndex={columnIndex}
|
||||
onMarkStart={handleMarkStart}
|
||||
onCancelMark={handleCancelMark}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCycleTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
{renderLegend()}
|
||||
<Legend />
|
||||
|
||||
|
||||
<FlatList
|
||||
@@ -411,9 +326,9 @@ export default function MenstrualCycleScreen() {
|
||||
const renderAnalysisTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
<View style={styles.analysisCard}>
|
||||
<Text style={styles.analysisTitle}>分析</Text>
|
||||
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
|
||||
<Text style={styles.analysisBody}>
|
||||
基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。
|
||||
{t('menstrual.screen.analysis.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() {
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>生理周期</Text>
|
||||
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
|
||||
<TouchableOpacity style={styles.headerIcon}>
|
||||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
@@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() {
|
||||
|
||||
<View style={styles.tabSwitcher}>
|
||||
{([
|
||||
{ key: 'cycle', label: '生理周期' },
|
||||
{ key: 'analysis', label: '分析' },
|
||||
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
|
||||
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
|
||||
] as { key: TabKey; label: string }[]).map((tab) => {
|
||||
const active = activeTab === tab.key;
|
||||
return (
|
||||
@@ -525,32 +440,7 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
@@ -569,206 +459,7 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user