DriftLedger/src/app/transaction/[id].tsx
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00

555 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 交易详情页plan.md「1.1 transaction/[id]」)。
*
* 从 ledger.transactions 按 id 查找,展示完整交易信息:
* - 日期 / 摘要 / 对方 / flag
* - 全部 postings(账户 / 金额 / 币种 / cost / price)
* - 标签 / 链接 / 元数据
* - 原始 .bean 文本
*/
import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/ledger';
import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const ledger = useLedgerStore(s => s.ledger);
const tx = useMemo(
() => ledger?.transactions.find(txn => txn.id === id) ?? null,
[ledger, id],
);
const [linkModalVisible, setLinkModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isEditable = tx ? tx.source.endsWith('main.bean') : false;
// 找出所有共享同一个链接标签的关联交易
const relatedTransactions = useMemo(() => {
if (!ledger || !tx || !tx.links || tx.links.length === 0) return [];
return ledger.transactions.filter(
t => t.id !== id && t.links.some(l => tx.links.includes(l))
);
}, [ledger, id, tx?.links]);
// 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => {
if (!ledger) return [];
const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
t.narration?.toLowerCase().includes(q) ||
t.payee?.toLowerCase().includes(q) ||
t.postings.some(p => p.account.toLowerCase().includes(q))
);
})
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签
if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
}
const addLinkToRaw = (raw: string, tag: string) => {
const lines = raw.split('\n');
const line = lines[0];
const commentIndex = line.indexOf(';');
if (line.includes(`^${tag}`)) {
return raw;
}
if (commentIndex !== -1) {
const beforeComment = line.substring(0, commentIndex).trimEnd();
const commentPart = line.substring(commentIndex);
lines[0] = `${beforeComment} ^${tag} ${commentPart}`;
} else {
lines[0] = line.trimEnd() + ` ^${tag}`;
}
return lines.join('\n');
};
const newCurrentRaw = addLinkToRaw(tx.raw, linkTag);
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
linkTag,
transaction1: {
id: tx.id,
date: tx.date,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings,
},
transaction2: {
id: targetTx.id,
date: targetTx.date,
payee: targetTx.payee,
narration: targetTx.narration,
postings: targetTx.postings,
},
});
setLinkModalVisible(false);
// 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e));
}
};
const handleUnlink = async (linkTag: string) => {
if (!tx) return;
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
const companionTxs = (ledger?.transactions ?? []).filter(
t => t.links.includes(linkTag) && t.source.endsWith('main.bean')
);
let newContent = mobileBean;
let newCurrentRaw = tx.raw;
for (const companion of companionTxs) {
const lines = companion.raw.split('\n');
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
linkTag,
unlinkedCount: companionTxs.length,
unlinkedTransactions: companionTxs.map(c => ({
id: c.id,
date: c.date,
payee: c.payee,
narration: c.narration,
postings: c.postings,
})),
});
// 解决 race condition直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e));
}
};
if (!tx) {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScreenHeader title={t('common.notFound')} />
<View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('transaction.notFoundDesc', { id })}
</Text>
</Card>
</View>
</SafeAreaView>
);
}
const isExpense = tx.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = tx.postings.some(p => p.account.startsWith('Income'));
const directionLabel = isExpense ? t('transaction.directionExpense') : isIncome ? t('transaction.directionIncome') : t('transaction.directionTransfer');
const directionColor = isExpense ? theme.colors.financial.expense
: isIncome ? theme.colors.financial.income
: theme.colors.financial.transfer;
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScreenHeader title={t('transaction.detailTitle')} />
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */}
<Card title={t('transaction.summary')}>
<View style={styles.summaryRow}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{tx.narration || tx.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''}
</Text>
</View>
<View style={[styles.directionBadge, { backgroundColor: directionColor }]}>
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text>
</View>
</View>
</Card>
{/* 资金流向 */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
<View style={{ gap: 8 }}>
{tx.postings.map((p, i) => {
const parts = p.account.split(':');
const rootType = parts[0];
const accountShortName = parts.slice(1).join(':') || p.account;
let categoryTag = t('account.title');
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
let tagBg = `${theme.colors.accent}15`;
let tagColor = theme.colors.accent;
if (rootType === 'Assets') {
categoryTag = t('account.rootTypes.Assets');
iconName = 'wallet-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Expenses') {
categoryTag = t('account.rootTypes.Expenses');
iconName = 'cart-outline';
tagBg = `${theme.colors.financial.expense}15`;
tagColor = theme.colors.financial.expense;
} else if (rootType === 'Income') {
categoryTag = t('account.rootTypes.Income');
iconName = 'cash-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Liabilities') {
categoryTag = t('account.rootTypes.Liabilities');
iconName = 'card-outline';
tagBg = `${theme.colors.warning}15`;
tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
<View
key={i}
style={[
styles.flowCardItem,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg,
},
]}
>
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
<Ionicons name={iconName} size={18} color={tagColor} />
</View>
<View style={{ flex: 1, marginLeft: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
{categoryTag}
</Text>
</View>
</View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
{accountShortName}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && (
<Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: {p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: @ {p.price}
</Text>
)}
</View>
</View>
);
})}
</View>
</Card>
{/* 标签 */}
{tx.tags.length > 0 && (
<Card title={t('transaction.tags')}>
<View style={styles.tagRow}>
{tx.tags.map(tag => (
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark }]}>#{tag}</Text>
</View>
))}
</View>
</Card>
)}
{/* 链接 */}
{(tx.links.length > 0 || isEditable) && (
<Card title={t('transaction.links')}>
<View style={styles.tagRow}>
{tx.links.map(link => (
<View key={link} style={[styles.tagContainer, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark, fontWeight: '700' }]}>^{link}</Text>
{isEditable && (
<Pressable onPress={() => handleUnlink(link)} style={{ marginLeft: 6 }}>
<Ionicons name="close-circle" size={14} color={theme.colors.accentDark} />
</Pressable>
)}
</View>
))}
</View>
{isEditable && (
<Pressable
onPress={() => setLinkModalVisible(true)}
style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]}
>
<Ionicons name="link-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 6, fontWeight: '700' }]}>
{t('transaction.linkTransaction')}
</Text>
</Pressable>
)}
{!isEditable && tx.links.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('transaction.readOnlyLinks')}
</Text>
)}
</Card>
)}
{/* 关联的交易列表 */}
{relatedTransactions.length > 0 && (
<Card title={t('transaction.relatedTransactions')}>
{relatedTransactions.map(item => (
<TransactionCard
key={item.id}
transaction={item}
onPress={(target) => router.push(`/transaction/${target.id}`)}
/>
))}
</Card>
)}
{/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
collapsible
defaultCollapsed
>
{Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{v}</Text>
</View>
))}
</Card>
)}
{/* 原始 .bean */}
<Card
title={t('transaction.rawBean')}
collapsible
defaultCollapsed
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('transaction.source')}: {tx.source}
</Text>
</Card>
{/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
{isEditable && (
<View style={styles.editActions}>
<Pressable
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => {
Alert.alert(t('transaction.deleteTitle'), t('transaction.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'), style: 'destructive',
onPress: async () => {
try {
await useLedgerStore.getState().deleteTransaction(tx.raw);
Alert.alert(t('transaction.deleteSuccess'));
router.back();
} catch (e) {
Alert.alert('Error', String(e));
}
},
},
]);
}}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.error, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="trash-outline" size={16} color={theme.colors.error} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.delete')}
</Text>
</Pressable>
</View>
)}
</ScrollView>
{/* 关联交易筛选模态框 */}
<Modal
visible={linkModalVisible}
animationType="slide"
transparent={false}
onRequestClose={() => setLinkModalVisible(false)}
>
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => setLinkModalVisible(false)}>
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('transaction.linkMobileTx')}
</Text>
</View>
<View style={styles.searchBarContainer}>
<TextInput
style={[commonStyles.input, { height: 40 }]}
placeholder={t('transaction.searchTxPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<FlatList
data={linkableTransactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
<TransactionCard
transaction={item}
onPress={() => handleLinkTransaction(item)}
/>
</View>
)}
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('transaction.noLinkableTx')}
</Text>
}
/>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
});