// 记账日期胶囊:点开应用内自绘日历浮层卡片 // v0.11.26:头部固定展示完整选中日期;已选今天时隐藏底部无效的「今天」;与统计页共用 MonthSwitcher // v0.11.24:从贴底全宽面板改为居中卡片——宽度、圆角、描边与记账页卡片一致,整体上移更协调 // v0.11.23:弃用系统 Material 日历弹窗(白底蓝字与暖色系界面风格割裂),改为与全站一致的暖色圆角面板 import React, { useMemo, useState } from 'react'; import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { COLORS, FONT_SIZE, RADIUS, SPACING } from '../constants'; import { addMonths, formatDate, formatDateFull, formatDateLabel, getDaysInMonth, getFirstDayOfMonth, getMonthName, getToday, parseDate, } from '../utils/dateUtils'; import MonthSwitcher from './MonthSwitcher'; interface Props { value: string; // YYYY-MM-DD onChange: (date: string) => void; // 无障碍文案前缀(如「记账日期」「记录日期」) label?: string; } const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; export default function DateField({ value, onChange, label = '记账日期' }: Props) { const [show, setShow] = useState(false); // 面板内先选、点「确定」才提交,避免滑动浏览月份时误改已保存的日期 const [pending, setPending] = useState(value); const [viewDate, setViewDate] = useState(() => parseDate(value)); // 不允许记未来:记账是已经发生的事,未来日期会污染本月/近 7 天统计 const today = getToday(); const year = viewDate.getFullYear(); const month = viewDate.getMonth() + 1; const now = new Date(); // 只能翻到当前月(含),未来月份整月不可选 const canGoNext = year < now.getFullYear() || (year === now.getFullYear() && month - 1 < now.getMonth()); // 固定 6 行(42 格):不同月份行数不同时面板高度不跳动 const cells = useMemo(() => { const list: (number | null)[] = []; const firstDay = getFirstDayOfMonth(year, month); const days = getDaysInMonth(year, month); for (let i = 0; i < firstDay; i++) list.push(null); for (let d = 1; d <= days; d++) list.push(d); while (list.length < 42) list.push(null); return list; }, [year, month]); const open = () => { setPending(value); setViewDate(parseDate(value)); setShow(true); }; return ( 📅 {formatDateLabel(value)} ▾ setShow(false)} > {/* 点遮罩关闭,不提交未确认的选择 */} setShow(false)} accessibilityRole="button" accessibilityLabel="关闭日期选择" /> 选择日期 {formatDateFull(pending)} {/* 月份切换:右侧到当前月为止;与统计页共用同一套箭头样式 */} setViewDate(addMonths(viewDate, -1))} onNext={() => setViewDate(addMonths(viewDate, 1))} nextDisabled={!canGoNext} /> {WEEKDAYS.map((w) => ( {w} ))} {cells.map((day, idx) => { if (day === null) { return ; } const dateStr = formatDate(new Date(year, month - 1, day)); const isFuture = dateStr > today; const isSelected = dateStr === pending; const isToday = dateStr === today; return ( setPending(dateStr)} disabled={isFuture} accessibilityRole="button" accessibilityLabel={`${month}月${day}日${isToday ? ' 今天' : ''}`} accessibilityState={{ selected: isSelected, disabled: isFuture }} > {day} ); })} {/* 已选今天时不再显示「今天」按钮(否则点了也没反应) */} {pending !== today ? ( { setPending(today); setViewDate(new Date()); }} hitSlop={6} accessibilityRole="button" accessibilityLabel="选择今天" > 今天 ) : ( )} setShow(false)} hitSlop={6} accessibilityRole="button" accessibilityLabel="取消" > 取消 { onChange(pending); setShow(false); }} accessibilityRole="button" accessibilityLabel="确定" > 确定 ); } const styles = StyleSheet.create({ wrap: { flexDirection: 'row', alignItems: 'center', }, chip: { flexDirection: 'row', alignItems: 'center', backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.pill, paddingHorizontal: SPACING.sm + 2, paddingVertical: SPACING.xs + 1, gap: 4, }, icon: { fontSize: FONT_SIZE.xs, }, text: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, chevron: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, fontWeight: '700', }, // ===== 居中浮层卡片(宽度/圆角/描边与记账页卡片一致) ===== overlay: { flex: 1, backgroundColor: COLORS.overlay, alignItems: 'center', justifyContent: 'center', paddingHorizontal: SPACING.md, }, card: { width: '100%', maxWidth: 420, backgroundColor: COLORS.surface, borderRadius: RADIUS.lg, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: SPACING.md, paddingTop: SPACING.md, paddingBottom: SPACING.sm, // 浮起感:暖色阴影 + Android elevation shadowColor: COLORS.text, shadowOpacity: 0.16, shadowRadius: 24, shadowOffset: { width: 0, height: 8 }, elevation: 12, }, sheetHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, sheetTitle: { fontSize: FONT_SIZE.lg, fontWeight: '800', color: COLORS.text, }, sheetSub: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, monthRow: { marginTop: SPACING.sm, }, weekRow: { flexDirection: 'row', marginTop: SPACING.sm, }, weekText: { width: '14.2857%', textAlign: 'center', fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, fontWeight: '600', }, grid: { flexDirection: 'row', flexWrap: 'wrap', marginTop: SPACING.xs, }, cell: { width: '14.2857%', height: 42, alignItems: 'center', justifyContent: 'center', }, day: { width: 36, height: 36, borderRadius: RADIUS.pill, alignItems: 'center', justifyContent: 'center', }, dayToday: { borderWidth: 1.5, borderColor: COLORS.accent, }, daySelected: { backgroundColor: COLORS.accent, }, dayText: { fontSize: FONT_SIZE.md, color: COLORS.text, fontWeight: '500', }, dayTextToday: { color: COLORS.accent, fontWeight: '700', }, dayTextSelected: { color: COLORS.white, fontWeight: '700', }, dayTextFuture: { color: COLORS.textTertiary, opacity: 0.45, }, footer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: SPACING.sm, paddingTop: SPACING.sm, paddingBottom: SPACING.xs, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: COLORS.border, }, todayBtn: { backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.pill, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, }, todayText: { fontSize: FONT_SIZE.sm, color: COLORS.accentDark, fontWeight: '700', }, footerActions: { flexDirection: 'row', alignItems: 'center', }, cancelBtn: { paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, }, cancelText: { fontSize: FONT_SIZE.md, color: COLORS.textSecondary, fontWeight: '600', }, confirmBtn: { backgroundColor: COLORS.accent, borderRadius: RADIUS.pill, paddingHorizontal: SPACING.lg, paddingVertical: SPACING.sm + 2, marginLeft: SPACING.xs, }, confirmText: { fontSize: FONT_SIZE.md, color: COLORS.white, fontWeight: '700', }, });