Ctrl K
ComunidadGuías…

src/screens/LedgerScreen.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  DeviceEventEmitter,
  FlatList,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaView } from 'react-native-safe-area-context';
import { COLORS, FONT_SIZE, LEDGER_EVENTS, RADIUS, SPACING, findCategory } from '../constants';
import {
  getDaySummaries,
  getMaxDailyExpense,
  getRecordsByDate,
  getRecordsByRange,
} from '../database/ledgerDB';
import {
  formatMoney,
  getMonthRange,
  getToday,
  parseDate,
  addMonths,
  getMonthName,
  getDaysInMonth,
} from '../utils/dateUtils';
import { hapticLight } from '../utils/haptics';
import { useToast } from '../hooks/useToast';
import { confirmDeleteRecord } from '../hooks/useDeleteRecord';
import { getCachedMembers, type MemberInfo } from '../sync/memberUtils';
import MonthHeatmap from '../components/MonthHeatmap';
import RecordList, { RecordRow } from '../components/RecordList';
import EditRecordModal from '../components/EditRecordModal';
import type { LedgerRecord, RecordType } from '../types';

type FilterType = 'all' | RecordType;

// 流水模式拍平后的列表项(虚拟化渲染;按收支记录分组)
type FlowItem = { kind: 'header'; date: string; count: number } | { kind: 'record'; record: LedgerRecord };

interface Props {
  active: boolean; // 当前 Tab 激活(App 常驻挂载,激活时滚回顶部)
}

export default function LedgerScreen({ active }: Props) {
  const [mode, setMode] = useState<'calendar' | 'list'>('calendar');
  const [viewDate, setViewDate] = useState(new Date());
  const [dailyExpense, setDailyExpense] = useState<Record<string, number>>({});
  const [maxExpense, setMaxExpense] = useState(0);
  const [selectedDate, setSelectedDate] = useState(getToday());
  const [dayRecords, setDayRecords] = useState<LedgerRecord[]>([]);

  // 流水模式
  const [monthRecords, setMonthRecords] = useState<LedgerRecord[]>([]);
  const [filterType, setFilterType] = useState<FilterType>('all');
  const [searchText, setSearchText] = useState('');
  const [members, setMembers] = useState<MemberInfo[]>([]); // 家庭成员缓存(v0.5 记账人标识)
  const [memberFilter, setMemberFilter] = useState(0); // 0=全部,>0 按记账人筛选(v0.9.1)
  const [editing, setEditing] = useState<LedgerRecord | null>(null); // 点击编辑的记录(v0.10)

  const { showToast } = useToast();

  const calendarScrollRef = useRef<ScrollView>(null);
  const listScrollRef = useRef<FlatList<FlowItem>>(null);

  const { start, end } = useMemo(() => getMonthRange(viewDate), [viewDate]);

  // 加载竞态守卫:挂载 / 切 Tab / 记一笔 / 导入 会并发触发多轮查询,
  // 慢的那轮后回来就会把新数据盖成旧的(首页与统计页都有这个守卫,本页此前缺)。
  // 月份与单日各自计数,互不误伤。
  const mountedRef = useRef(true);
  const monthSeq = useRef(0);
  const daySeq = useRef(0);
  useEffect(
    () => () => {
      mountedRef.current = false;
    },
    [],
  );

  const loadMonth = useCallback(async () => {
    const seq = ++monthSeq.current;
    try {
      const [days, max, records] = await Promise.all([
        getDaySummaries(start, end),
        getMaxDailyExpense(start, end),
        getRecordsByRange(start, end),
      ]);
      if (!mountedRef.current || seq !== monthSeq.current) return;
      const map: Record<string, number> = {};
      for (const d of days) map[d.date] = d.expense;
      setDailyExpense(map);
      setMaxExpense(max);
      setMonthRecords(records);
    } catch {
      if (mountedRef.current && seq === monthSeq.current) showToast('明细数据加载失败', 'error');
    }
  }, [start, end, showToast]);

  const loadDay = useCallback(async (date: string) => {
    const seq = ++daySeq.current;
    try {
      const records = await getRecordsByDate(date);
      if (!mountedRef.current || seq !== daySeq.current) return;
      setDayRecords(records);
    } catch {
      // 单日加载失败保持现状
    }
  }, []);

  useEffect(() => {
    loadMonth();
  }, [loadMonth]);

  useEffect(() => {
    loadDay(selectedDate);
  }, [selectedDate, loadDay]);

  useEffect(() => {
    getCachedMembers().then(setMembers); // 记账人标识(v0.5)
  }, []);

  // v0.11.21:成员被移出家庭/退出登录后,残留的筛选 id 会让列表与合计一直查空,
  // 而成员≤1 时筛选胶囊整行隐藏、无处复位 → 随成员列表变化自动回到「全部」
  useEffect(() => {
    setMemberFilter((cur) => (cur > 0 && !members.some((m) => m.id === cur) ? 0 : cur));
  }, [members]);

  // Tab 激活时滚回顶部(按当前模式滚动对应列表,v0.5.4)
  useEffect(() => {
    if (!active) {
      // 切走本页时收起编辑页,避免回到明细时停留在半开编辑态(v0.11.5)
      setEditing(null);
      return;
    }
    if (mode === 'calendar') calendarScrollRef.current?.scrollTo({ y: 0, animated: false });
    else listScrollRef.current?.scrollToOffset({ offset: 0, animated: false });
  }, [active, mode]);

  // Tab 激活时重载数据(激活刷新保证数据即时,v0.5.6)
  useEffect(() => {
    if (!active) return;
    loadMonth();
    loadDay(selectedDate);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active]);

  // 全局刷新
  useEffect(() => {
    const subs = [
      DeviceEventEmitter.addListener(LEDGER_EVENTS.RECORDED, () => {
        loadMonth();
        loadDay(selectedDate);
      }),
      DeviceEventEmitter.addListener(LEDGER_EVENTS.DATA_IMPORTED, () => {
        loadMonth();
        loadDay(selectedDate);
      }),
      // 登录态/同步完成 → 刷新成员缓存(v0.5)
      DeviceEventEmitter.addListener(LEDGER_EVENTS.AUTH_CHANGED, () => {
        getCachedMembers().then(setMembers);
      }),
      DeviceEventEmitter.addListener(LEDGER_EVENTS.SYNC_DONE, () => {
        getCachedMembers().then(setMembers);
      }),
    ];
    return () => subs.forEach((s) => s.remove());
  }, [loadMonth, loadDay, selectedDate]);

  // 切月:选中日同步到目标月同日(超出月末则 clamp,与系统日历一致)
  // 不在 setViewDate 的 updater 里再调 setSelectedDate —— updater 必须纯(StrictMode 会双调用)
  const changeMonth = useCallback(
    (delta: number) => {
      const next = addMonths(viewDate, delta);
      const [y, m, d] = selectedDate.split('-').map(Number);
      // 目标月与当前选中日同月才需要同步(跨月选中日始终在 viewDate 月内)
      const sameMonth = y === viewDate.getFullYear() && m === viewDate.getMonth() + 1;
      setViewDate(next);
      if (sameMonth) {
        const day = Math.min(d, getDaysInMonth(next.getFullYear(), next.getMonth() + 1));
        setSelectedDate(
          `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
        );
      }
    },
    [viewDate, selectedDate],
  );

  const handleDelete = useCallback(
    (record: LedgerRecord) => {
      confirmDeleteRecord(record.id, (msg, isError) => showToast(msg, isError ? 'error' : 'success'));
    },
    [showToast],
  );

  // 流水筛选(records 按记账人 / 类型 / 关键词)
  const filteredRecords = useMemo(() => {
    let list = monthRecords;
    if (memberFilter !== 0) list = list.filter((r) => r.userId === memberFilter);
    if (filterType !== 'all') {
      list = list.filter((r) => r.type === filterType);
    }
    if (searchText.trim()) {
      const kw = searchText.trim().toLowerCase();
      list = list.filter((r) => {
        const cat = findCategory(r.category, r.type);
        return (
          r.note.toLowerCase().includes(kw) ||
          cat.label.toLowerCase().includes(kw) ||
          cat.key.toLowerCase().includes(kw)
        );
      });
    }
    return list;
  }, [monthRecords, memberFilter, filterType, searchText]);

  // 日历模式:当日记录按记账人筛选 + 当日收支小计(与筛选一致)
  const visibleDayRecords = useMemo(
    () => (memberFilter === 0 ? dayRecords : dayRecords.filter((r) => r.userId === memberFilter)),
    [dayRecords, memberFilter],
  );
  const dayTotals = useMemo(() => {
    let exp = 0;
    let inc = 0;
    for (const r of visibleDayRecords) {
      if (r.type === 'expense' && !r.reimbursable) exp += r.amount;
      else if (r.type === 'income') inc += r.amount;
    }
    return { exp, inc };
  }, [visibleDayRecords]);

  // 拍平为虚拟化列表数据(日期头 + 记录行按时间降序分组)
  const flowItems = useMemo<FlowItem[]>(() => {
    type Mixed = { date: string; timestamp: number; item: FlowItem };
    const mixed: Mixed[] = [];
    for (const r of filteredRecords)
      mixed.push({ date: r.date, timestamp: r.timestamp, item: { kind: 'record', record: r } });
    mixed.sort((a, b) => (a.date === b.date ? b.timestamp - a.timestamp : b.date < a.date ? -1 : 1));

    const items: FlowItem[] = [];
    let curDate = '';
    let count = 0;
    const flush = () => {
      if (curDate) items.push({ kind: 'header', date: curDate, count });
    };
    for (const m of mixed) {
      if (m.date !== curDate) {
        flush();
        curDate = m.date;
        count = 1;
      } else {
        count += 1;
      }
      items.push(m.item);
    }
    flush();
    return items;
  }, [filteredRecords]);

  // 合计直接走 filteredRecords,与下面列出的行同口径。
  // 此前只跟记账人筛选,切到「只看支出」或搜索关键词后合计仍按全月算,
  // 数字和列表互相矛盾(且把「支出排除待报销」这条规则在 UI 层又抄了一遍)。
  const monthTotal = useMemo(() => {
    let exp = 0;
    let inc = 0;
    for (const r of filteredRecords) {
      if (r.type === 'expense' && !r.reimbursable) exp += r.amount;
      else if (r.type === 'income') inc += r.amount;
    }
    return { exp, inc };
  }, [filteredRecords]);

  const isCurrentMonth = useMemo(() => {
    const now = new Date();
    return viewDate.getFullYear() === now.getFullYear() && viewDate.getMonth() === now.getMonth();
  }, [viewDate]);

  const selectedLabel = useMemo(() => {
    const d = parseDate(selectedDate);
    const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()];
    return `${selectedDate} ${week}`;
  }, [selectedDate]);

  const renderFlowItem = useCallback(
    ({ item }: { item: FlowItem }) => {
      if (item.kind === 'header') {
        return (
          <View style={styles.groupHeader}>
            <Text style={styles.groupDate}>{item.date}</Text>
            <Text style={styles.groupCount}>{item.count} 笔</Text>
          </View>
        );
      }
      return (
        <View style={styles.flowRecordWrap}>
          <RecordRow record={item.record} onDelete={handleDelete} onEdit={setEditing} members={members} />
        </View>
      );
    },
    [handleDelete, members],
  );

  // 成员筛选(家庭多成员账本才显示;0=全部)
  const renderMemberFilter = () => {
    if (members.length <= 1) return null;
    const chips: { id: number; label: string }[] = [
      { id: 0, label: '👨‍👩‍👧 全部' },
      ...members.map((m) => ({ id: m.id, label: `${m.avatarEmoji} ${m.displayName}` })),
    ];
    return (
      <ScrollView
        horizontal
        showsHorizontalScrollIndicator={false}
        style={styles.memberScroll}
        contentContainerStyle={styles.memberChipsRow}
      >
        {chips.map((c) => (
          <Pressable
            key={c.id}
            style={[styles.memberChip, memberFilter === c.id && styles.memberChipActive]}
            onPress={() => {
              setMemberFilter(c.id);
              hapticLight();
            }}
            accessibilityRole="button"
            accessibilityLabel={c.id === 0 ? '全部成员' : `只看 ${c.label}`}
            accessibilityState={{ selected: memberFilter === c.id }}
          >
            <Text
              style={[styles.memberChipText, memberFilter === c.id && styles.memberChipTextActive]}
              numberOfLines={1}
            >
              {c.label}
            </Text>
          </Pressable>
        ))}
      </ScrollView>
    );
  };

  return (
    <SafeAreaView style={styles.safe} edges={['top']}>
      <StatusBar style="dark" />
      {/* 列表页与编辑页互斥显示(display 切换,同 App 切 Tab;编辑页为普通页面流,触摸/安全区与首页一致) */}
      <View style={editing ? styles.pageHidden : styles.page}>
        {mode === 'calendar' ? (
          <ScrollView
            ref={calendarScrollRef}
            style={styles.scroll}
            contentContainerStyle={styles.content}
            showsVerticalScrollIndicator={false}
            keyboardShouldPersistTaps="handled"
          >
            <View style={styles.titleRow}>
              <Text style={styles.pageTitle}>明细</Text>
              <View style={styles.modeSwitch}>
                {(['calendar', 'list'] as const).map((m) => (
                  <Pressable
                    key={m}
                    style={[styles.modeBtn, mode === m && styles.modeBtnActive]}
                    onPress={() => setMode(m)}
                    accessibilityRole="tab"
                    accessibilityLabel={m === 'calendar' ? '日历模式' : '流水模式'}
                    accessibilityState={{ selected: mode === m }}
                  >
                    <Text style={[styles.modeText, mode === m && styles.modeTextActive]}>
                      {m === 'calendar' ? '日历' : '流水'}
                    </Text>
                  </Pressable>
                ))}
              </View>
            </View>

            {/* 月份切换 + 热力图 */}
            <View style={styles.card}>
              <View style={styles.monthRow}>
                <Pressable
                  style={styles.monthBtn}
                  onPress={() => changeMonth(-1)}
                  hitSlop={8}
                  accessibilityRole="button"
                  accessibilityLabel="上一月"
                >
                  <Text style={styles.monthBtnText}>‹</Text>
                </Pressable>
                <Pressable
                  onPress={() => {
                    if (!isCurrentMonth) {
                      setViewDate(new Date());
                      setSelectedDate(getToday());
                    }
                  }}
                  accessibilityRole="button"
                  accessibilityLabel="回到本月"
                >
                  <Text style={styles.monthTitle}>{getMonthName(viewDate)}</Text>
                </Pressable>
                <Pressable
                  style={styles.monthBtn}
                  onPress={() => changeMonth(1)}
                  hitSlop={8}
                  accessibilityRole="button"
                  accessibilityLabel="下一月"
                >
                  <Text style={styles.monthBtnText}>›</Text>
                </Pressable>
              </View>
              <MonthHeatmap
                year={viewDate.getFullYear()}
                month={viewDate.getMonth() + 1}
                dailyExpense={dailyExpense}
                maxExpense={maxExpense}
                selectedDate={selectedDate}
                onSelectDate={setSelectedDate}
              />
            </View>

            {renderMemberFilter()}

            {/* 选中日期明细 */}
            <View style={styles.dayHeader}>
              <Text style={styles.dayTitle}>{selectedLabel}</Text>
              <View style={styles.daySummary}>
                {dayTotals.exp > 0 ? (
                  <Text style={styles.daySummaryText}>
                    支出{' '}
                    <Text style={{ color: COLORS.expense, fontWeight: '700' }}>
                      ¥{formatMoney(dayTotals.exp)}
                    </Text>
                  </Text>
                ) : null}
                {dayTotals.inc > 0 ? (
                  <Text style={styles.daySummaryText}>
                    收入{' '}
                    <Text style={{ color: COLORS.income, fontWeight: '700' }}>
                      ¥{formatMoney(dayTotals.inc)}
                    </Text>
                  </Text>
                ) : null}
              </View>
            </View>
            <RecordList
              records={visibleDayRecords}
              onDelete={handleDelete}
              onEdit={setEditing}
              emptyText="这一天还没有记录"
              members={members}
            />
          </ScrollView>
        ) : (
          /* 流水模式:FlatList 虚拟化(长月数据不卡顿) */
          <FlatList
            ref={listScrollRef}
            style={styles.scroll}
            contentContainerStyle={styles.content}
            data={flowItems}
            keyExtractor={(item) => (item.kind === 'header' ? `h-${item.date}` : `r-${item.record.id}`)}
            renderItem={renderFlowItem}
            showsVerticalScrollIndicator={false}
            keyboardShouldPersistTaps="handled"
            ListHeaderComponent={
              <View>
                <View style={styles.titleRow}>
                  <Text style={styles.pageTitle}>明细</Text>
                  <View style={styles.modeSwitch}>
                    {(['calendar', 'list'] as const).map((m) => (
                      <Pressable
                        key={m}
                        style={[styles.modeBtn, mode === m && styles.modeBtnActive]}
                        onPress={() => setMode(m)}
                        accessibilityRole="tab"
                        accessibilityLabel={m === 'calendar' ? '日历模式' : '流水模式'}
                        accessibilityState={{ selected: mode === m }}
                      >
                        <Text style={[styles.modeText, mode === m && styles.modeTextActive]}>
                          {m === 'calendar' ? '日历' : '流水'}
                        </Text>
                      </Pressable>
                    ))}
                  </View>
                </View>
                {renderMemberFilter()}
                {/* 月份切换(流水模式与日历模式共享 viewDate,v0.5.1) */}
                <View style={styles.monthBar}>
                  <Pressable
                    style={styles.monthBtn}
                    onPress={() => changeMonth(-1)}
                    hitSlop={8}
                    accessibilityRole="button"
                    accessibilityLabel="上一月"
                  >
                    <Text style={styles.monthBtnText}>‹</Text>
                  </Pressable>
                  <Pressable
                    onPress={() => {
                      if (!isCurrentMonth) {
                        setViewDate(new Date());
                        setSelectedDate(getToday());
                      }
                    }}
                    accessibilityRole="button"
                    accessibilityLabel="回到本月"
                  >
                    <Text style={styles.monthTitle}>{getMonthName(viewDate)}</Text>
                  </Pressable>
                  <Pressable
                    style={styles.monthBtn}
                    onPress={() => changeMonth(1)}
                    hitSlop={8}
                    accessibilityRole="button"
                    accessibilityLabel="下一月"
                  >
                    <Text style={styles.monthBtnText}>›</Text>
                  </Pressable>
                </View>
                {/* 流水筛选 */}
                <View style={styles.filterRow}>
                  <View style={styles.chips}>
                    {(
                      [
                        ['all', '全部'],
                        ['expense', '支出'],
                        ['income', '收入'],
                      ] as [FilterType, string][]
                    ).map(([k, label]) => (
                      <Pressable
                        key={k}
                        style={[styles.filterChip, filterType === k && styles.filterChipActive]}
                        onPress={() => setFilterType(k)}
                        accessibilityRole="button"
                        accessibilityLabel={`筛选${label}`}
                        accessibilityState={{ selected: filterType === k }}
                      >
                        <Text
                          style={[styles.filterChipText, filterType === k && styles.filterChipTextActive]}
                        >
                          {label}
                        </Text>
                      </Pressable>
                    ))}
                  </View>
                </View>
                <View style={styles.searchRow}>
                  <View style={styles.searchWrap}>
                    <TextInput
                      style={styles.searchInput}
                      placeholder="搜索备注 / 分类"
                      placeholderTextColor={COLORS.textTertiary}
                      value={searchText}
                      onChangeText={setSearchText}
                      maxLength={20}
                    />
                    {searchText.length > 0 ? (
                      <Pressable
                        style={styles.searchClear}
                        onPress={() => setSearchText('')}
                        hitSlop={8}
                        accessibilityRole="button"
                        accessibilityLabel="清除搜索"
                      >
                        <Text style={styles.searchClearText}>✕</Text>
                      </Pressable>
                    ) : null}
                  </View>
                  <View style={styles.monthTotal}>
                    <Text style={styles.monthTotalText}>
                      收{' '}
                      <Text style={{ color: COLORS.income, fontWeight: '700' }}>
                        {formatMoney(monthTotal.inc)}
                      </Text>
                    </Text>
                    <Text style={styles.monthTotalText}>
                      支{' '}
                      <Text style={{ color: COLORS.expense, fontWeight: '700' }}>
                        {formatMoney(monthTotal.exp)}
                      </Text>
                    </Text>
                  </View>
                </View>
                {flowItems.length === 0 ? <RecordList records={[]} emptyText="没有符合条件的记录" /> : null}
              </View>
            }
          />
        )}
      </View>
      <View style={editing ? styles.page : styles.pageHidden}>
        {/* 点击记录编辑(v0.10):保存后 RECORDED 事件刷新列表并自动同步 */}
        <EditRecordModal visible={editing !== null} record={editing} onClose={() => setEditing(null)} />
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  safe: {
    flex: 1,
    backgroundColor: COLORS.background,
  },
  page: {
    flex: 1,
  },
  pageHidden: {
    flex: 1,
    display: 'none',
  },
  scroll: {
    flex: 1,
  },
  content: {
    padding: SPACING.lg,
    paddingBottom: SPACING.xxl,
  },
  titleRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: SPACING.md,
  },
  pageTitle: {
    fontSize: FONT_SIZE.xxl,
    fontWeight: '800',
    color: COLORS.text,
  },
  modeSwitch: {
    flexDirection: 'row',
    backgroundColor: COLORS.bgAlt,
    borderRadius: RADIUS.pill,
    padding: 3,
  },
  modeBtn: {
    paddingHorizontal: SPACING.md,
    paddingVertical: 6,
    borderRadius: RADIUS.pill,
  },
  modeBtnActive: {
    backgroundColor: COLORS.surface,
  },
  modeText: {
    fontSize: FONT_SIZE.sm,
    color: COLORS.textSecondary,
    fontWeight: '600',
  },
  modeTextActive: {
    color: COLORS.accentDark,
    fontWeight: '700',
  },
  card: {
    backgroundColor: COLORS.surface,
    borderRadius: RADIUS.lg,
    borderWidth: 1,
    borderColor: COLORS.border,
    padding: SPACING.md,
  },
  monthRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: SPACING.sm + 2,
  },
  monthBtn: {
    width: 34,
    height: 34,
    borderRadius: RADIUS.pill,
    backgroundColor: COLORS.bgAlt,
    alignItems: 'center',
    justifyContent: 'center',
  },
  monthBtnText: {
    fontSize: FONT_SIZE.xl,
    color: COLORS.textSecondary,
    lineHeight: 24,
  },
  monthTitle: {
    fontSize: FONT_SIZE.lg,
    fontWeight: '700',
    color: COLORS.text,
  },
  dayHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'baseline',
    marginTop: SPACING.lg,
    marginBottom: SPACING.sm,
  },
  dayTitle: {
    fontSize: FONT_SIZE.lg,
    fontWeight: '700',
    color: COLORS.text,
  },
  daySummary: {
    flexDirection: 'row',
    gap: SPACING.md,
  },
  daySummaryText: {
    fontSize: FONT_SIZE.sm,
    color: COLORS.textSecondary,
  },
  filterRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: SPACING.sm,
  },
  chips: {
    flexDirection: 'row',
    gap: SPACING.sm,
  },
  filterChip: {
    paddingHorizontal: SPACING.md,
    paddingVertical: 6,
    borderRadius: RADIUS.pill,
    backgroundColor: COLORS.surface,
    borderWidth: 1,
    borderColor: COLORS.border,
  },
  filterChipActive: {
    backgroundColor: COLORS.accent,
    borderColor: COLORS.accent,
  },
  filterChipText: {
    fontSize: FONT_SIZE.sm,
    color: COLORS.textSecondary,
    fontWeight: '600',
  },
  filterChipTextActive: {
    color: COLORS.white,
    fontWeight: '700',
  },
  memberScroll: {
    flexGrow: 0,
    marginBottom: SPACING.sm,
  },
  memberChipsRow: {
    gap: SPACING.sm,
    paddingVertical: 2,
  },
  memberChip: {
    paddingHorizontal: SPACING.md,
    paddingVertical: 6,
    borderRadius: RADIUS.pill,
    backgroundColor: COLORS.surface,
    borderWidth: 1,
    borderColor: COLORS.border,
  },
  memberChipActive: {
    backgroundColor: COLORS.accent,
    borderColor: COLORS.accent,
  },
  memberChipText: {
    fontSize: FONT_SIZE.sm,
    color: COLORS.textSecondary,
    fontWeight: '600',
  },
  memberChipTextActive: {
    color: COLORS.white,
    fontWeight: '700',
  },
  searchRow: {
    flexDirection: 'row',
    gap: SPACING.sm,
    marginBottom: SPACING.md,
  },
  searchWrap: {
    flex: 1,
    position: 'relative',
    justifyContent: 'center',
  },
  searchInput: {
    backgroundColor: COLORS.surface,
    borderRadius: RADIUS.md,
    borderWidth: 1,
    borderColor: COLORS.border,
    paddingHorizontal: SPACING.md,
    paddingRight: 36,
    paddingVertical: 9,
    fontSize: FONT_SIZE.md,
    color: COLORS.text,
  },
  searchClear: {
    position: 'absolute',
    right: 8,
    width: 22,
    height: 22,
    borderRadius: RADIUS.pill,
    backgroundColor: COLORS.bgAlt,
    alignItems: 'center',
    justifyContent: 'center',
  },
  searchClearText: {
    fontSize: FONT_SIZE.xs - 1,
    color: COLORS.textTertiary,
    fontWeight: '600',
  },
  monthTotal: {
    flexDirection: 'row',
    gap: SPACING.sm,
    alignItems: 'center',
    backgroundColor: COLORS.surface,
    borderRadius: RADIUS.md,
    borderWidth: 1,
    borderColor: COLORS.border,
    paddingHorizontal: SPACING.sm + 2,
    flexShrink: 1,
  },
  monthTotalText: {
    fontSize: FONT_SIZE.xs,
    color: COLORS.textSecondary,
  },
  monthBar: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: SPACING.sm,
  },
  groupHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'baseline',
    marginTop: SPACING.sm,
    marginBottom: SPACING.xs + 2,
  },
  flowRecordWrap: {
    marginBottom: SPACING.sm,
  },
  groupDate: {
    fontSize: FONT_SIZE.sm,
    color: COLORS.textSecondary,
    fontWeight: '600',
  },
  groupCount: {
    fontSize: FONT_SIZE.xs,
    color: COLORS.textTertiary,
  },
});