Add add trans

This commit is contained in:
2026-05-28 00:27:51 +03:00
parent 8fe4245bd3
commit 46fbfd9da8
37 changed files with 3654 additions and 3058 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

-214
View File
@@ -1,214 +0,0 @@
// Android.jsx — Simplified Android (Material 3) device frame
// Status bar + top app bar + content + gesture nav + keyboard.
// Based on Figma M3 spec. No dependencies, no image assets.
const MD_C = {
surface: '#f4fbf8',
surfaceVariant: '#dae5e1',
inverseOnSurface: '#ecf2ef',
secondaryContainer: '#cde8e1',
primaryFixedDim: '#83d5c6',
onSurface: '#171d1b',
onSurfaceVar: '#49454f',
onPrimaryContainer: '#00201c',
primary: '#006a60',
frameBorder: 'rgba(116,119,117,0.5)',
};
// ─────────────────────────────────────────────────────────────
// Status bar (time left, wifi/cell/battery right)
// ─────────────────────────────────────────────────────────────
function AndroidStatusBar({ dark = false }) {
const c = dark ? '#fff' : MD_C.onSurface;
return (
<div style={{
height: 40, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 16px',
position: 'relative',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{/* time left */}
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
</div>
{/* camera punch-hole (center) */}
<div style={{
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
}} />
{/* status icons right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', paddingRight: 2 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
</svg>
</div>
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Top app bar (Material 3 small/medium)
// ─────────────────────────────────────────────────────────────
function AndroidAppBar({ title = 'Title', large = false }) {
const iconDot = (
<div style={{
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
</div>
);
return (
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
{iconDot}
{!large && (
<span style={{
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</span>
)}
{large && <div style={{ flex: 1 }} />}
{iconDot}
</div>
{large && (
<div style={{
padding: '16px 16px 20px',
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// List item (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidListItem({ headline, supporting, leading }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{leading && (
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: MD_C.primary, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 500, flexShrink: 0,
}}>{leading}</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
{supporting && (
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Gesture nav bar (pill)
// ─────────────────────────────────────────────────────────────
function AndroidNavBar({ dark = false }) {
return (
<div style={{
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 108, height: 4, borderRadius: 2,
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
}} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame — wraps everything
// ─────────────────────────────────────────────────────────────
function AndroidDevice({
children, width = 412, height = 892, dark = false,
title, large = false, keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 18, overflow: 'hidden',
background: dark ? '#1d1b20' : MD_C.surface,
border: `8px solid ${MD_C.frameBorder}`,
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
<AndroidStatusBar dark={dark} />
{title !== undefined && <AndroidAppBar title={title} large={large} />}
<div style={{ flex: 1, overflow: 'auto' }}>
{children}
</div>
{keyboard && <AndroidKeyboard />}
<AndroidNavBar dark={dark} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — Gboard (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidKeyboard() {
let _k = 0;
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
<div key={_k++} style={{
height: 46, borderRadius: r, flex, minWidth: minW,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Roboto, system-ui', fontSize: fs,
color: MD_C.onPrimaryContainer,
}}>{l}</div>
);
const row = (keys, style = {}) => (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
{keys.map(l => key(l))}
</div>
);
return (
<div style={{
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* navbar spacer (icons omitted) */}
<div style={{ height: 44 }} />
{/* key rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
<div style={{ display: 'flex', gap: 6 }}>
{key('', { bg: MD_C.surfaceVariant })}
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
{['z','x','c','v','b','n','m'].map(l => key(l))}
</div>
{key('', { bg: MD_C.surfaceVariant })}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
{key(',', { bg: MD_C.surfaceVariant })}
{key('', { flex: 3, minW: 154 })}
{key('.', { bg: MD_C.surfaceVariant })}
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
</div>
</div>
</div>
);
}
Object.assign(window, {
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
});
-334
View File
@@ -1,334 +0,0 @@
// Shared bits for budget app wireframes
// Tokens come from CSS vars set on .wf-root in index.html so dark/light can swap.
// ─── Icons (thin line, 24px stroke 1.5) ────────────────────────────
const Ico = ({ d, size = 20, stroke = 1.5, fill = 'none', style }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}
stroke="currentColor" strokeWidth={stroke}
strokeLinecap="round" strokeLinejoin="round" style={style}>
{d}
</svg>
);
const Icons = {
search: <Ico d={<><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>} />,
bell: <Ico d={<><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9z" /><path d="M10 21a2 2 0 0 0 4 0" /></>} />,
menu: <Ico d={<><path d="M4 7h16M4 12h16M4 17h16"/></>} />,
chev: <Ico d={<><path d="m6 9 6 6 6-6"/></>} />,
chevRt: <Ico d={<><path d="m9 6 6 6-6 6"/></>} size={16} />,
plus: <Ico d={<><path d="M12 5v14M5 12h14"/></>} stroke={2} />,
filter: <Ico d={<><path d="M4 5h16M7 12h10M10 19h4"/></>} />,
home: <Ico d={<><path d="M4 10 12 4l8 6v9a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1z"/></>} />,
stats: <Ico d={<><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></>} />,
wallet: <Ico d={<><rect x="3" y="6" width="18" height="14" rx="2"/><path d="M16 13h2M3 10h18"/></>} />,
user: <Ico d={<><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>} />,
card: <Ico d={<><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h3"/></>} />,
cash: <Ico d={<><rect x="2" y="6" width="20" height="12" rx="1"/><circle cx="12" cy="12" r="3"/></>} />,
bank: <Ico d={<><path d="M3 10h18L12 3 3 10z"/><path d="M5 10v8M9 10v8M15 10v8M19 10v8M3 21h18"/></>} />,
pig: <Ico d={<><path d="M4 13a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v2a4 4 0 0 1-4 4h-1l-1 2h-2l-1-2H9l-1 2H6l-1-2a4 4 0 0 1-1-3v-1z"/><circle cx="16" cy="13" r=".7" fill="currentColor"/></>} />,
food: <Ico d={<><path d="M5 3v8a3 3 0 0 0 6 0V3M8 11v10M16 3c-2 2-2 6 0 8v10"/></>} />,
cart: <Ico d={<><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.2a2 2 0 0 0 2-1.5L21 8H6"/><circle cx="9" cy="20" r="1.2"/><circle cx="18" cy="20" r="1.2"/></>} />,
car: <Ico d={<><path d="M3 14l2-6a2 2 0 0 1 2-1.5h10a2 2 0 0 1 2 1.5l2 6v4H3v-4z"/><circle cx="7.5" cy="17" r="1.3"/><circle cx="16.5" cy="17" r="1.3"/></>} />,
house: <Ico d={<><path d="M4 11 12 4l8 7v9h-5v-6H9v6H4z"/></>} />,
film: <Ico d={<><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 9h18M3 15h18M7 5v14M17 5v14"/></>} />,
health: <Ico d={<><path d="M12 4v16M4 12h16"/></>} />,
gift: <Ico d={<><rect x="3" y="8" width="18" height="5"/><path d="M12 8v13M3 13h18v8H3zM7 8a3 3 0 1 1 5-2 3 3 0 1 1 5 2"/></>} />,
more: <Ico d={<><circle cx="6" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="18" cy="12" r="1.2" fill="currentColor"/></>} />,
arrowUp: <Ico d={<><path d="M7 17 17 7M9 7h8v8"/></>} size={14} />,
arrowDn: <Ico d={<><path d="M7 7l10 10M9 17h8V9"/></>} size={14} />,
eye: <Ico d={<><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>} />,
};
// ─── Caveat annotation tag (handdrawn comment) ─────────────────────
function Note({ children, style }) {
return (
<span style={{
fontFamily: 'Caveat, cursive', fontSize: 15, lineHeight: 1,
color: 'var(--accent)', letterSpacing: 0.2,
...style,
}}>{children}</span>
);
}
// Small arrow used with Note callouts
function NoteArrow({ rot = 0, len = 28, style }) {
return (
<svg width={len + 8} height={20} viewBox={`0 0 ${len + 8} 20`}
style={{ transform: `rotate(${rot}deg)`, ...style }}>
<path d={`M2 10 Q ${len * 0.5} 2 ${len} 12`} fill="none"
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" />
<path d={`M${len - 5} 8 L${len + 2} 12 L${len - 3} 14`} fill="none"
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
// ─── Money formatting ──────────────────────────────────────────────
const fmt = (n) => {
const s = Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
return (n < 0 ? '' : '') + s + ' ₽';
};
const fmtNoCur = (n) => {
return Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
};
// ─── Donut chart with hover ────────────────────────────────────────
function Donut({ data, size = 180, thickness = 26, active = null, onSegment }) {
const total = data.reduce((s, d) => s + d.value, 0);
const r = size / 2;
const ri = r - thickness;
let a0 = -Math.PI / 2;
const arcs = data.map((d, i) => {
const sweep = (d.value / total) * Math.PI * 2;
const a1 = a0 + sweep;
const big = sweep > Math.PI ? 1 : 0;
const isActive = active === i;
const rOff = isActive ? 4 : 0;
const ro = r + rOff;
const rii = ri + rOff;
const x0 = r + ro * Math.cos(a0), y0 = r + ro * Math.sin(a0);
const x1 = r + ro * Math.cos(a1), y1 = r + ro * Math.sin(a1);
const x2 = r + rii * Math.cos(a1), y2 = r + rii * Math.sin(a1);
const x3 = r + rii * Math.cos(a0), y3 = r + rii * Math.sin(a0);
const path = `M${x0},${y0} A${ro},${ro} 0 ${big} 1 ${x1},${y1} L${x2},${y2} A${rii},${rii} 0 ${big} 0 ${x3},${y3} Z`;
a0 = a1;
return { path, color: d.color, i };
});
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{arcs.map(a => (
<path key={a.i} d={a.path} fill={a.color}
opacity={active === null || active === a.i ? 1 : 0.35}
onClick={() => onSegment && onSegment(a.i)}
style={{ cursor: onSegment ? 'pointer' : 'default', transition: 'opacity .2s' }} />
))}
</svg>
);
}
// Stacked horizontal bar — alt visualization
function StackBar({ data, height = 16, radius = 8 }) {
const total = data.reduce((s, d) => s + d.value, 0);
let acc = 0;
return (
<div style={{
width: '100%', height, borderRadius: radius, overflow: 'hidden',
display: 'flex', background: 'var(--line)',
}}>
{data.map((d, i) => {
const w = (d.value / total) * 100;
const el = (
<div key={i} title={d.label}
style={{ width: `${w}%`, background: d.color, height: '100%' }} />
);
acc += w;
return el;
})}
</div>
);
}
// ─── Category palette (sage/terracotta family — calm) ───────────────
const CATS = [
{ id: 'food', label: 'Продукты', icon: Icons.cart, color: '#8aa6a0' },
{ id: 'rent', label: 'Жильё', icon: Icons.house, color: '#c89a86' },
{ id: 'transp', label: 'Транспорт', icon: Icons.car, color: '#b3a589' },
{ id: 'cafe', label: 'Кафе', icon: Icons.food, color: '#9fb38a' },
{ id: 'enter', label: 'Досуг', icon: Icons.film, color: '#a99cb9' },
{ id: 'other', label: 'Другое', icon: Icons.more, color: '#b8b5ac' },
];
// Mock month spend by category
const SPEND = [
{ id: 'food', value: 14_200 },
{ id: 'rent', value: 32_000 },
{ id: 'transp', value: 5_600 },
{ id: 'cafe', value: 8_400 },
{ id: 'enter', value: 4_200 },
{ id: 'other', value: 2_800 },
];
const SPEND_TOTAL = SPEND.reduce((s, d) => s + d.value, 0);
const DONUT_DATA = SPEND.map(s => {
const c = CATS.find(c => c.id === s.id);
return { value: s.value, color: c.color, label: c.label, id: s.id };
});
// ─── Accounts ──────────────────────────────────────────────────────
const ACCOUNTS = [
{ id: 'all', label: 'Все счета', short: 'Все', icon: Icons.wallet, balance: 184_320 },
{ id: 'card', label: 'Карта', short: 'Карта', icon: Icons.card, balance: 142_500 },
{ id: 'cash', label: 'Наличные', short: 'Кэш', icon: Icons.cash, balance: 12_820 },
{ id: 'save', label: 'Копилка', short: 'Копилка', icon: Icons.pig, balance: 29_000 },
];
// ─── Transactions (compact list) ────────────────────────────────────
const TX = [
{ id: 1, cat: 'food', merchant: 'Лента', acc: 'card', amount: -2_340, when: 'Сегодня, 19:42' },
{ id: 2, cat: 'cafe', merchant: 'Кофе Хауз', acc: 'card', amount: -480, when: 'Сегодня, 09:15' },
{ id: 3, cat: 'transp', merchant: 'Метро', acc: 'card', amount: -62, when: 'Сегодня, 08:50' },
{ id: 4, cat: 'food', merchant: 'Перекрёсток', acc: 'cash', amount: -1_120, when: 'Вчера, 21:08' },
{ id: 5, cat: 'enter', merchant: 'Кинотеатр', acc: 'card', amount: -650, when: 'Вчера, 19:30' },
{ id: 6, cat: 'rent', merchant: 'Аренда квартиры',acc: 'card', amount: -32_000,when: '21 мая' },
{ id: 7, cat: 'other', merchant: 'Зарплата', acc: 'card', amount: 95_000, when: '20 мая' },
{ id: 8, cat: 'transp', merchant: 'Яндекс Такси', acc: 'card', amount: -340, when: '20 мая' },
{ id: 9, cat: 'cafe', merchant: 'Шоколадница', acc: 'cash', amount: -720, when: '19 мая' },
{ id: 10, cat: 'food', merchant: 'Магнит', acc: 'card', amount: -890, when: '19 мая' },
];
// ─── Compact transaction row ───────────────────────────────────────
function TxRow({ tx, dense = true }) {
const cat = CATS.find(c => c.id === tx.cat) || CATS[CATS.length - 1];
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: dense ? '8px 16px' : '12px 16px',
borderBottom: '1px solid var(--line)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: cat.color + '26', color: cat.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>{cat.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: 'var(--ink)', fontWeight: 500,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{tx.merchant}</div>
<div style={{ fontSize: 11, color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
<span>{cat.label}</span><span>·</span><span>{tx.when}</span>
</div>
</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: 14, fontVariantNumeric: 'tabular-nums',
color: tx.amount > 0 ? 'var(--pos)' : 'var(--ink)',
fontWeight: 500,
}}>
{tx.amount > 0 ? '+' : ''}{fmtNoCur(tx.amount)}
</div>
</div>
);
}
// ─── Day group header ──────────────────────────────────────────────
function DayHeader({ label, total }) {
return (
<div style={{
display: 'flex', justifyContent: 'space-between',
padding: '10px 16px 4px',
fontSize: 11, letterSpacing: 0.6, textTransform: 'uppercase',
color: 'var(--ink-2)',
}}>
<span>{label}</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace' }}>{fmtNoCur(total)} </span>
</div>
);
}
// ─── Bottom nav ────────────────────────────────────────────────────
function BottomNav({ active = 0 }) {
const items = [
{ icon: Icons.home, label: 'Главная' },
{ icon: Icons.stats, label: 'Аналитика' },
{ icon: Icons.wallet, label: 'Счета' },
{ icon: Icons.user, label: 'Профиль' },
];
return (
<div style={{
display: 'flex', borderTop: '1px solid var(--line)',
background: 'var(--paper)',
}}>
{items.map((it, i) => (
<div key={i} style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 3, padding: '8px 0 6px',
color: i === active ? 'var(--accent)' : 'var(--ink-2)',
}}>
<div style={{
padding: i === active ? '2px 14px' : 0,
background: i === active ? 'var(--accent-soft)' : 'transparent',
borderRadius: 12, display: 'flex',
}}>{it.icon}</div>
<span style={{ fontSize: 10, fontWeight: i === active ? 600 : 400 }}>{it.label}</span>
</div>
))}
</div>
);
}
// ─── FAB ───────────────────────────────────────────────────────────
function FAB({ bottom = 70, right = 16 }) {
return (
<div style={{
position: 'absolute', bottom, right,
width: 52, height: 52, borderRadius: 16,
background: 'var(--accent)', color: 'var(--paper)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
}}>{Icons.plus}</div>
);
}
// ─── Wireframe app bar (compact, neutral) ──────────────────────────
function WfBar({ title, sub, right }) {
return (
<div style={{
padding: '14px 16px 6px',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
{sub && (
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>
{sub}
</div>
)}
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', letterSpacing: -0.3 }}>
{title}
</div>
</div>
<div style={{ display: 'flex', gap: 4, color: 'var(--ink-2)' }}>
{right}
</div>
</div>
);
}
// ─── Variation label (printed below frame on canvas) ────────────────
function VLabel({ n, title, axes }) {
return (
<div style={{
width: 412, padding: '14px 4px 0',
fontFamily: 'DM Sans, sans-serif',
}}>
<div style={{
display: 'flex', gap: 8, alignItems: 'baseline',
}}>
<span style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 11,
color: 'var(--ink-2)',
}}>0{n}</span>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>{title}</span>
</div>
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{axes.map((a, i) => (
<span key={i} style={{
fontSize: 10, padding: '2px 6px',
border: '1px dashed var(--line-2)', borderRadius: 6,
color: 'var(--ink-2)', letterSpacing: 0.2,
}}>{a}</span>
))}
</div>
</div>
);
}
Object.assign(window, {
Ico, Icons, Note, NoteArrow,
fmt, fmtNoCur,
Donut, StackBar,
CATS, SPEND, SPEND_TOTAL, DONUT_DATA,
ACCOUNTS, TX,
TxRow, DayHeader, BottomNav, FAB, WfBar, VLabel,
});
-966
View File
@@ -1,966 +0,0 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
-172
View File
@@ -1,172 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Главный экран — Бюджет · вайрфреймы</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet" />
<style>
:root {
/* Light tokens */
--paper: #f6f4ef;
--paper-2: #efece5;
--card-soft: #edeae3;
--ink: #1c1c1a;
--ink-2: #6b6b66;
--line: #d8d5cc;
--line-2: #b8b5ac;
--accent: #4a8a82;
--accent-soft:#dde9e6;
--pos: #6f8c69;
--neg: #b3675a;
--canvas-bg: #efece5;
}
[data-theme="dark"] {
--paper: #19191a;
--paper-2: #232325;
--card-soft: #232325;
--ink: #ece9e2;
--ink-2: #8d8a83;
--line: #2e2d2a;
--line-2: #4a4845;
--accent: #76b3a9;
--accent-soft:#23332f;
--pos: #92b58a;
--neg: #d18d7e;
--canvas-bg: #111112;
}
html, body { margin: 0; padding: 0; background: var(--canvas-bg); }
body {
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
/* Wireframe root sets dark/light tokens for all frames */
.wf-root { color: var(--ink); }
/* Remove webkit scrollbars on hscrolls */
.wf-root *::-webkit-scrollbar { display: none; }
</style>
<!-- React + Babel -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="android-frame.jsx"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="common.jsx"></script>
<script type="text/babel" src="variants.jsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "dark"
}/*EDITMODE-END*/;
// Custom Android frame that uses our CSS-var palette (so dark/light flows through)
function PhoneShell({ children }) {
return (
<div style={{
width: 412, height: 892, borderRadius: 18, overflow: 'hidden',
background: 'var(--paper)',
border: '8px solid var(--line-2)',
boxShadow: '0 24px 60px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
{/* Status bar */}
<div style={{
height: 32, padding: '0 16px', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
position: 'relative', fontSize: 12, color: 'var(--ink)',
fontFamily: 'DM Sans, sans-serif',
}}>
<span style={{ fontWeight: 500 }}>9:30</span>
<div style={{
position: 'absolute', left: '50%', top: 6, transform: 'translateX(-50%)',
width: 18, height: 18, borderRadius: '50%', background: 'var(--ink)',
opacity: 0.85,
}} />
<div style={{ display: 'flex', gap: 4, alignItems: 'center', opacity: 0.85 }}>
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill="currentColor"/></svg>
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill="currentColor"/></svg>
<svg width="16" height="14" viewBox="0 0 16 16"><rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill="currentColor"/><rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill="currentColor"/></svg>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', position: 'relative' }}>
{children}
</div>
{/* Bottom nav */}
<BottomNav active={0} />
{/* Gesture pill */}
<div style={{ height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--paper)' }}>
<div style={{ width: 96, height: 4, borderRadius: 2, background: 'var(--ink)', opacity: 0.35 }} />
</div>
</div>
);
}
// Variation meta
const VARIANTS = [
{ n: 1, title: 'Segmented account tabs',
Component: V1,
axes: ['Счёт: горизонтальные таб-пиллы', 'KPI: баланс + доходырасходы', 'Фильтр: bottom-sheet триггер', 'Список: группировка по дням'] },
];
function Stage() {
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => {
document.documentElement.setAttribute('data-theme', tweaks.theme);
}, [tweaks.theme]);
return (
<div className="wf-root" data-screen-label="Главный экран">
<DesignCanvas>
<DCSection id="phones" title="Главный экран">
{VARIANTS.map(v => (
<DCArtboard key={v.n}
id={`v${v.n}`}
label={`0${v.n} · ${v.title}`}
width={412} height={892}>
<PhoneShell><v.Component /></PhoneShell>
</DCArtboard>
))}
</DCSection>
</DesignCanvas>
<TweaksPanel>
<TweakSection label="Тема">
<TweakRadio
label="Режим"
value={tweaks.theme}
onChange={v => setTweak('theme', v)}
options={[
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
]}
/>
</TweakSection>
</TweaksPanel>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<Stage />);
</script>
</body>
</html>
-530
View File
@@ -1,530 +0,0 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
-743
View File
@@ -1,743 +0,0 @@
// 5 home-screen wireframe variations.
// Each is a function returning the inner-content of an AndroidDevice (status bar + nav are added by the frame).
// =====================================================================
// Shared sub-blocks
// =====================================================================
function MonthChip({ label = 'Май 2026', tight }) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: tight ? '4px 8px' : '6px 10px',
border: '1px solid var(--line)', borderRadius: 999,
fontSize: 12, color: 'var(--ink)', background: 'var(--paper)',
}}>
<span>{label}</span>
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
</div>
);
}
function KPI({ label, value, hint, accent, mono = true }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{
fontSize: 10, color: 'var(--ink-2)',
letterSpacing: 0.6, textTransform: 'uppercase',
}}>{label}</div>
<div style={{
fontSize: 17, fontWeight: 600, color: accent || 'var(--ink)',
fontFamily: mono ? 'JetBrains Mono, monospace' : undefined,
fontVariantNumeric: 'tabular-nums', letterSpacing: -0.3,
}}>{value}</div>
{hint && (
<div style={{ fontSize: 10, color: 'var(--ink-2)' }}>{hint}</div>
)}
</div>
);
}
function CategoryChip({ cat, active, count, onClick, dense }) {
return (
<div onClick={onClick} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: dense ? '4px 9px' : '6px 11px',
borderRadius: 999, flexShrink: 0,
border: '1px solid ' + (active ? 'var(--ink)' : 'var(--line)'),
background: active ? 'var(--ink)' : 'var(--paper)',
color: active ? 'var(--paper)' : 'var(--ink)',
fontSize: 12, fontWeight: active ? 600 : 400,
cursor: 'pointer',
}}>
{cat?.color && (
<div style={{
width: 8, height: 8, borderRadius: 99,
background: active ? 'var(--paper)' : cat.color,
}}/>
)}
<span>{cat ? cat.label : 'Все'}</span>
{count !== undefined && (
<span style={{ fontSize: 10, opacity: 0.7 }}>· {count}</span>
)}
</div>
);
}
// Horizontal scroll strip
function HScroll({ children, gap = 8, pad = 16 }) {
return (
<div style={{
display: 'flex', gap, padding: `0 ${pad}px`,
overflowX: 'auto', scrollbarWidth: 'none',
WebkitOverflowScrolling: 'touch',
}}>{children}</div>
);
}
// =====================================================================
// V1 — Tab pills + KPI strip + donut + bottomsheet-trigger filter
// + day-grouped transactions
// =====================================================================
function V1() {
const [acc, setAcc] = React.useState('all');
const [filterCat, setFilterCat] = React.useState(null);
const [active, setActive] = React.useState(null);
const visibleTx = filterCat ? TX.filter(t => t.cat === filterCat) : TX;
// Group transactions by `when` field (we'll use first comma-split as day key).
const groups = React.useMemo(() => {
const byDay = new Map();
for (const t of visibleTx) {
const day = t.when.split(',')[0].trim();
if (!byDay.has(day)) byDay.set(day, []);
byDay.get(day).push(t);
}
return Array.from(byDay, ([day, items]) => ({
day,
items,
total: items.reduce((s, t) => s + (t.amount < 0 ? -t.amount : 0), 0),
}));
}, [visibleTx]);
const activeCat = filterCat && CATS.find(c => c.id === filterCat);
return (
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
{/* Header */}
<div style={{ padding: '14px 16px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>Бюджет</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink)' }}>Май 2026</div>
</div>
<div style={{ display: 'flex', gap: 6, color: 'var(--ink-2)' }}>
{Icons.search}{Icons.bell}
</div>
</div>
{/* Account tabs (segmented pills, scrollable) */}
<HScroll>
{ACCOUNTS.map(a => (
<div key={a.id} onClick={() => setAcc(a.id)} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 999, flexShrink: 0,
border: '1px solid ' + (acc === a.id ? 'var(--ink)' : 'var(--line)'),
background: acc === a.id ? 'var(--ink)' : 'var(--paper)',
color: acc === a.id ? 'var(--paper)' : 'var(--ink)',
fontSize: 13, fontWeight: acc === a.id ? 600 : 400,
}}>
<span style={{ display: 'flex', opacity: acc === a.id ? 1 : 0.7 }}>{a.icon}</span>
<span>{a.short}</span>
</div>
))}
</HScroll>
{/* Month KPI strip — balance + доходы / расходы only */}
<div style={{
margin: '12px 16px 14px',
border: '1px solid var(--line)', borderRadius: 14,
padding: '14px 14px 14px', background: 'var(--paper)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<span style={{ fontSize: 11, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Баланс</span>
<span style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 22, fontWeight: 600,
color: 'var(--ink)', letterSpacing: -0.4,
}}>184 320 </span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1px 1fr', gap: 12, alignItems: 'center' }}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 2 }}>Доходы</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 18, fontWeight: 600,
color: 'var(--pos)', letterSpacing: -0.3,
}}>+95 000 </div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 2 }}>Расходы</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 18, fontWeight: 600,
color: 'var(--neg)', letterSpacing: -0.3,
}}>67 200 </div>
</div>
</div>
</div>
{/* Donut + legend */}
<div style={{
margin: '0 16px 14px', padding: 14,
border: '1px solid var(--line)', borderRadius: 14,
display: 'flex', gap: 14, alignItems: 'center',
}}>
<div style={{ position: 'relative', width: 130, height: 130, flexShrink: 0 }}>
<Donut data={DONUT_DATA} size={130} thickness={22}
active={active} onSegment={(i) => {
const id = DONUT_DATA[i].id;
setFilterCat(filterCat === id ? null : id);
setActive(active === i ? null : i);
}} />
<div style={{
position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none',
}}>
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Расходы</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 14, fontWeight: 600 }}>67 200 </div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
{DONUT_DATA.slice(0, 4).map((d, i) => {
const pct = Math.round((d.value / SPEND_TOTAL) * 100);
return (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: d.color }} />
<span style={{ flex: 1, color: 'var(--ink)' }}>{d.label}</span>
<span style={{
fontFamily: 'JetBrains Mono, monospace', color: 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}>{pct}%</span>
</div>
);
})}
<div style={{ fontSize: 10, color: 'var(--ink-2)' }}>+ ещё 2 категории </div>
</div>
</div>
{/* Section header */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 16px 8px', alignItems: 'center' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Транзакции</div>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>{visibleTx.length} операций</span>
</div>
{/* Bottomsheet-trigger filter (from V2) */}
<div
onClick={() => { setFilterCat(null); setActive(null); }}
style={{
margin: '0 16px 0', padding: '10px 12px',
border: '1px solid var(--line)', borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--paper)', cursor: 'pointer',
}}>
<span style={{ display: 'flex', color: 'var(--ink-2)' }}>{Icons.filter}</span>
<span style={{ flex: 1, fontSize: 13, color: 'var(--ink)', display: 'flex', alignItems: 'center', gap: 6 }}>
{activeCat ? (
<>
<div style={{ width: 8, height: 8, borderRadius: 2, background: activeCat.color }} />
<span>{activeCat.label}</span>
</>
) : (
<span>Все категории · все типы</span>
)}
</span>
<span style={{
fontSize: 11, padding: '2px 6px', borderRadius: 99,
background: 'var(--accent-soft)', color: 'var(--accent)', fontWeight: 600,
fontFamily: 'JetBrains Mono, monospace',
}}>{visibleTx.length}</span>
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
</div>
{/* TX list grouped by day */}
<div style={{ marginTop: 4 }}>
{groups.map(g => (
<React.Fragment key={g.day}>
<DayHeader label={g.day} total={g.total} />
{g.items.map(tx => <TxRow key={tx.id} tx={tx} />)}
</React.Fragment>
))}
</div>
<FAB />
</div>
);
}
// =====================================================================
// V2 — Account dropdown + hero card + bottomsheet filter
// =====================================================================
function V2() {
const [acc] = React.useState('card');
const accObj = ACCOUNTS.find(a => a.id === acc);
return (
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
{/* Account dropdown header */}
<div style={{ padding: '14px 16px 6px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 10px 6px 8px', borderRadius: 999,
border: '1px solid var(--line)', background: 'var(--paper)',
}}>
<div style={{
width: 24, height: 24, borderRadius: 6, background: 'var(--accent-soft)',
color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{accObj.icon}</div>
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.15 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>{accObj.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-2)' }}>4 счёта · переключить</span>
</div>
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
</div>
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
{Icons.search}{Icons.bell}
</div>
</div>
{/* Hero balance card — editorial */}
<div style={{
margin: '14px 16px 16px',
padding: '18px 18px 16px',
background: 'var(--ink)', color: 'var(--paper)',
borderRadius: 18, position: 'relative', overflow: 'hidden',
}}>
<div style={{ fontSize: 10, opacity: 0.6, letterSpacing: 0.6, textTransform: 'uppercase' }}>Баланс счёта</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 30, fontWeight: 600,
letterSpacing: -0.6, marginTop: 2, marginBottom: 14,
}}>142 500 </div>
{/* Inline sparkline */}
<svg width="100%" height="40" viewBox="0 0 280 40" style={{ marginBottom: 10 }}>
<path d="M0 28 L20 24 L40 26 L60 18 L80 22 L100 14 L120 18 L140 10 L160 14 L180 8 L200 12 L220 6 L240 10 L260 4 L280 8"
fill="none" stroke="var(--accent)" strokeWidth="1.5" />
<path d="M0 28 L20 24 L40 26 L60 18 L80 22 L100 14 L120 18 L140 10 L160 14 L180 8 L200 12 L220 6 L240 10 L260 4 L280 8 L280 40 L0 40 Z"
fill="var(--accent)" opacity="0.18" />
</svg>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
<div>
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Доход</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>+95 000</div>
</div>
<div>
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Расход</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>67 200</div>
</div>
<div>
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Остаток</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>27 800</div>
</div>
</div>
</div>
{/* Donut centered, with big center stat */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 16px 8px', alignItems: 'baseline' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Расходы по категориям</div>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>Май</span>
</div>
<div style={{ display: 'flex', justifyContent: 'center', position: 'relative', marginBottom: 4 }}>
<div style={{ position: 'relative', width: 160, height: 160 }}>
<Donut data={DONUT_DATA} size={160} thickness={20} />
<div style={{
position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Всего</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>67 200 </div>
<div style={{ fontSize: 10, color: 'var(--ink-2)', marginTop: 2 }}>6 категорий</div>
</div>
</div>
</div>
{/* Filter row (sheet trigger) */}
<div style={{
margin: '6px 16px 0', padding: '10px 12px',
border: '1px solid var(--line)', borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--paper)',
}}>
<span style={{ display: 'flex', color: 'var(--ink-2)' }}>{Icons.filter}</span>
<span style={{ flex: 1, fontSize: 13, color: 'var(--ink)' }}>Все категории · все типы</span>
<span style={{
fontSize: 11, padding: '2px 6px', borderRadius: 99,
background: 'var(--accent-soft)', color: 'var(--accent)', fontWeight: 600,
}}>132</span>
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
</div>
{/* Compact TX list */}
<div style={{ marginTop: 8 }}>
{TX.slice(0, 5).map(tx => <TxRow key={tx.id} tx={tx} />)}
</div>
{/* Sheet peek note */}
<div style={{
position: 'absolute', left: 12, right: 12, bottom: 90,
display: 'flex', alignItems: 'center', gap: 6,
pointerEvents: 'none',
}}>
<NoteArrow rot={-15} len={32} />
<Note>Тап открывает bottom sheet с фильтрами</Note>
</div>
<FAB />
</div>
);
}
// =====================================================================
// V3 — Swipeable account cards carousel + stacked bar + chips
// =====================================================================
function V3() {
const [filterCat, setFilterCat] = React.useState(null);
return (
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
{/* Title */}
<div style={{ padding: '14px 16px 4px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', letterSpacing: -0.3 }}>Привет, Аня</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)' }}>Май 2026 · 27 800 свободно</div>
</div>
<div style={{ display: 'flex', gap: 6, color: 'var(--ink-2)' }}>{Icons.bell}</div>
</div>
{/* Account cards carousel (peek style) */}
<div style={{ marginTop: 12, marginBottom: 12, position: 'relative' }}>
<HScroll gap={10}>
{ACCOUNTS.map((a, i) => (
<div key={a.id} style={{
width: 220, flexShrink: 0,
padding: '14px 14px 12px',
borderRadius: 16,
border: '1px solid ' + (i === 1 ? 'var(--ink)' : 'var(--line)'),
background: i === 1 ? 'var(--ink)' : 'var(--paper)',
color: i === 1 ? 'var(--paper)' : 'var(--ink)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{
width: 28, height: 28, borderRadius: 8,
background: i === 1 ? 'rgba(255,255,255,0.12)' : 'var(--accent-soft)',
color: i === 1 ? 'var(--paper)' : 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{a.icon}</div>
<span style={{
fontSize: 10, opacity: i === 1 ? 0.6 : 0.5,
textTransform: 'uppercase', letterSpacing: 0.6,
}}>{a.id === 'all' ? 'сводно' : 'счёт'}</span>
</div>
<div style={{ fontSize: 12, opacity: 0.7, marginBottom: 2 }}>{a.label}</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 20, fontWeight: 600,
letterSpacing: -0.3,
}}>{fmtNoCur(a.balance)} </div>
</div>
))}
</HScroll>
{/* Page dots */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 5, marginTop: 10 }}>
{ACCOUNTS.map((_, i) => (
<div key={i} style={{
width: i === 1 ? 14 : 5, height: 5, borderRadius: 99,
background: i === 1 ? 'var(--ink)' : 'var(--line-2)',
}} />
))}
</div>
</div>
{/* 2x2 KPI */}
<div style={{
margin: '0 16px 14px', padding: 14,
border: '1px solid var(--line)', borderRadius: 14,
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14,
}}>
<KPI label="Доходы" value="+95 000 ₽" accent="var(--pos)" hint="3 транзакции" />
<KPI label="Расходы" value="67 200 ₽" accent="var(--neg)" hint="42 транзакции" />
<KPI label="Бюджет" value="95 000 ₽" hint="лимит на месяц" />
<KPI label="Осталось" value="27 800 ₽" hint="на 9 дней" />
</div>
{/* Stacked bar — alt visualization */}
<div style={{ margin: '0 16px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Структура расходов</div>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>67 200 </span>
</div>
<StackBar data={DONUT_DATA} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 10 }}>
{DONUT_DATA.map(d => {
const pct = Math.round((d.value / SPEND_TOTAL) * 100);
return (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11 }}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: d.color }} />
<span style={{ color: 'var(--ink)' }}>{d.label}</span>
<span style={{ color: 'var(--ink-2)', fontFamily: 'JetBrains Mono, monospace' }}>{pct}%</span>
</div>
);
})}
</div>
</div>
{/* Inline category chips */}
<div style={{ padding: '0 16px 6px', display: 'flex', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Последние операции</div>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>Все </span>
</div>
<HScroll>
<CategoryChip active={!filterCat} onClick={() => setFilterCat(null)} dense />
{CATS.slice(0, 5).map(c => (
<CategoryChip key={c.id} cat={c} dense
active={filterCat === c.id}
onClick={() => setFilterCat(filterCat === c.id ? null : c.id)} />
))}
</HScroll>
<div style={{ marginTop: 8 }}>
{TX.slice(0, 4).map(tx => <TxRow key={tx.id} tx={tx} />)}
</div>
<FAB />
</div>
);
}
// =====================================================================
// V4 — Editorial title + tiny tab pills (top-right) + centered donut KPI
// =====================================================================
function V4() {
const [acc, setAcc] = React.useState('all');
const [filterCat, setFilterCat] = React.useState(null);
return (
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
{/* Top: editorial title + tiny tabs */}
<div style={{
padding: '18px 16px 4px',
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
}}>
<div>
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>2026</div>
<div style={{
fontSize: 38, fontWeight: 600, color: 'var(--ink)',
letterSpacing: -1, lineHeight: 1, marginTop: 2,
}}>Май.</div>
</div>
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
{Icons.eye}{Icons.bell}
</div>
</div>
{/* Tiny segmented tabs */}
<div style={{ padding: '14px 16px 0' }}>
<div style={{
display: 'inline-flex', padding: 3, borderRadius: 99,
background: 'var(--card-soft)', border: '1px solid var(--line)',
}}>
{ACCOUNTS.map(a => (
<div key={a.id} onClick={() => setAcc(a.id)} style={{
padding: '5px 11px', borderRadius: 99, fontSize: 12,
background: acc === a.id ? 'var(--paper)' : 'transparent',
boxShadow: acc === a.id ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
fontWeight: acc === a.id ? 600 : 400,
color: acc === a.id ? 'var(--ink)' : 'var(--ink-2)',
}}>{a.short}</div>
))}
</div>
</div>
{/* Number-first month summary */}
<div style={{ padding: '16px 16px 10px', display: 'flex', gap: 18 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Баланс</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 24, fontWeight: 600,
color: 'var(--ink)', letterSpacing: -0.4,
}}>184 320 </div>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: 'var(--pos)', display: 'flex' }}>{Icons.arrowUp}</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>95 000 </span>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>доход</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: 'var(--neg)', display: 'flex' }}>{Icons.arrowDn}</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>67 200 </span>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>расход</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--ink-2)' }}>=</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>27 800 </span>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>остаток</span>
</div>
</div>
</div>
{/* Donut with big center number */}
<div style={{ position: 'relative', width: 130, height: 130 }}>
<Donut data={DONUT_DATA} size={130} thickness={14} />
<div style={{
position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 17, fontWeight: 600,
color: 'var(--neg)', letterSpacing: -0.3,
}}>67.2K</div>
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>траты</div>
</div>
</div>
</div>
{/* Divider */}
<div style={{ height: 1, background: 'var(--line)', margin: '6px 16px 0' }} />
{/* Filter chips */}
<div style={{ padding: '14px 16px 8px', display: 'flex', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Операции</div>
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>132 в мае</span>
</div>
<HScroll>
<CategoryChip active={!filterCat} onClick={() => setFilterCat(null)} dense />
{CATS.map(c => (
<CategoryChip key={c.id} cat={c} dense
active={filterCat === c.id}
onClick={() => setFilterCat(filterCat === c.id ? null : c.id)} />
))}
</HScroll>
<div style={{ marginTop: 6 }}>
<DayHeader label="Сегодня · 24 мая" total={2882} />
{TX.slice(0, 3).map(tx => <TxRow key={tx.id} tx={tx} />)}
<DayHeader label="Вчера · 23 мая" total={1770} />
{TX.slice(3, 5).map(tx => <TxRow key={tx.id} tx={tx} />)}
</div>
<FAB />
</div>
);
}
// =====================================================================
// V5 — Mini account grid + 2x2 KPI + donut with chip-legend (dual filter)
// =====================================================================
function V5() {
const [acc, setAcc] = React.useState('card');
const [filterCat, setFilterCat] = React.useState(null);
return (
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
<div style={{ padding: '14px 16px 4px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>Бюджет · Май 2026</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink)' }}>Обзор</div>
</div>
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>{Icons.search}{Icons.bell}</div>
</div>
{/* Mini account grid 2x2 */}
<div style={{
margin: '12px 16px 0',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8,
}}>
{ACCOUNTS.map(a => {
const isActive = a.id === acc;
return (
<div key={a.id} onClick={() => setAcc(a.id)} style={{
padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (isActive ? 'var(--ink)' : 'var(--line)'),
background: isActive ? 'var(--ink)' : 'var(--paper)',
color: isActive ? 'var(--paper)' : 'var(--ink)',
display: 'flex', flexDirection: 'column', gap: 4,
position: 'relative',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ display: 'flex', opacity: isActive ? 1 : 0.7 }}>{a.icon}</span>
<span style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.2 }}>{a.label}</span>
{isActive && (
<div style={{
position: 'absolute', top: 6, right: 8,
width: 6, height: 6, borderRadius: 99, background: 'var(--accent)',
}} />
)}
</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 14, fontWeight: 600,
letterSpacing: -0.2,
}}>{fmtNoCur(a.balance)} </div>
</div>
);
})}
</div>
{/* 2x2 KPI grid */}
<div style={{
margin: '12px 16px 0',
display: 'grid', gridTemplateColumns: '1fr 1fr',
border: '1px solid var(--line)', borderRadius: 12, overflow: 'hidden',
}}>
{[
{ l: 'Доходы', v: '+95 000 ₽', c: 'var(--pos)' },
{ l: 'Расходы', v: '67 200 ₽', c: 'var(--neg)' },
{ l: 'Остаток', v: '27 800 ₽', c: 'var(--ink)' },
{ l: 'Бюджет', v: '70% / 95K', c: 'var(--ink)' },
].map((k, i) => (
<div key={i} style={{
padding: 12,
borderRight: i % 2 === 0 ? '1px solid var(--line)' : 'none',
borderBottom: i < 2 ? '1px solid var(--line)' : 'none',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{k.l}</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600,
color: k.c, marginTop: 2,
}}>{k.v}</div>
</div>
))}
</div>
{/* Donut + chip-legend */}
<div style={{
margin: '12px 16px 12px', padding: 12,
border: '1px solid var(--line)', borderRadius: 14,
display: 'flex', gap: 12, alignItems: 'center',
}}>
<div style={{ position: 'relative', width: 108, height: 108, flexShrink: 0 }}>
<Donut data={DONUT_DATA} size={108} thickness={16}
active={filterCat ? DONUT_DATA.findIndex(d => d.id === filterCat) : null}
onSegment={i => setFilterCat(filterCat === DONUT_DATA[i].id ? null : DONUT_DATA[i].id)} />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' }}>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600 }}>67.2K</div>
<div style={{ fontSize: 8, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Расходы</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{DONUT_DATA.map(d => {
const active = filterCat === d.id;
return (
<div key={d.id}
onClick={() => setFilterCat(active ? null : d.id)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 8px', borderRadius: 99, fontSize: 11,
border: '1px solid ' + (active ? 'var(--ink)' : 'var(--line)'),
background: active ? 'var(--ink)' : 'var(--paper)',
color: active ? 'var(--paper)' : 'var(--ink)',
cursor: 'pointer',
}}>
<div style={{ width: 6, height: 6, borderRadius: 99, background: d.color }} />
<span>{d.label}</span>
</div>
);
})}
</div>
</div>
{/* Filter + list */}
<div style={{ padding: '0 16px 6px', display: 'flex', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>
Операции {filterCat && <span style={{ color: 'var(--ink-2)', fontWeight: 400 }}>· {CATS.find(c => c.id === filterCat)?.label}</span>}
</div>
{filterCat && (
<span onClick={() => setFilterCat(null)} style={{ fontSize: 11, color: 'var(--accent)', cursor: 'pointer' }}>Сбросить</span>
)}
</div>
<div>
{(filterCat ? TX.filter(t => t.cat === filterCat) : TX).slice(0, 4).map(tx => <TxRow key={tx.id} tx={tx} />)}
</div>
<FAB />
</div>
);
}
Object.assign(window, { V1, V2, V3, V4, V5, CategoryChip, KPI, MonthChip, HScroll });
+54 -1
View File
@@ -80,5 +80,58 @@
"onboardingSubtitle": "Tell us your name to get started.",
"onboardingNameLabel": "Your name",
"onboardingNameHint": "How should we address you?",
"onboardingContinue": "Continue"
"onboardingContinue": "Continue",
"txNewTitle": "New transaction",
"txEditTitle": "Edit transaction",
"txTypeExpense": "Expense",
"txTypeIncome": "Income",
"txTypeTransfer": "Transfer",
"txAmountHint": "0",
"txCategoryLabel": "Category",
"txAccountLabel": "Account",
"txAccountFromLabel": "From",
"txAccountToLabel": "To",
"txDateTimeLabel": "Date & time",
"txBalanceAfter": "Balance after",
"txTransferAfter": "After transfer",
"txNoteLabel": "Note",
"txNoteHint": "e.g. Groceries for the week…",
"txSaveButton": "Add transaction",
"txSaveEditButton": "Save changes",
"txTransferButton": "Transfer {amount}",
"@txTransferButton": {
"placeholders": {
"amount": { "type": "String" }
}
},
"txDeleteButton": "Delete",
"txDeleteConfirmTitle": "Delete transaction?",
"txDeleteConfirmMessage": "This action cannot be undone.",
"txDeleteConfirmYes": "Delete",
"txDeleteConfirmNo": "Cancel",
"txPickCategory": "Pick a category",
"txPickAccount": "Pick an account",
"pickerAccountTitle": "Account",
"pickerAccountSubtitle": "{count, plural, one{{count} account} other{{count} accounts}}",
"@pickerAccountSubtitle": {
"placeholders": {
"count": { "type": "int" }
}
},
"pickerCategoryTitle": "Category",
"pickerCategorySubtitle": "{count, plural, one{{count} category} other{{count} categories}}",
"@pickerCategorySubtitle": {
"placeholders": {
"count": { "type": "int" }
}
},
"pickerAddAccount": "Add account",
"pickerCreateCategory": "Create category",
"comingSoonShort": "Coming soon",
"txValidationAmountRequired": "Enter an amount",
"txValidationAccountRequired": "Pick an account",
"txValidationCategoryRequired": "Pick a category",
"txValidationTransferSameAccount": "Source and destination must differ",
"txValidationTransferDestRequired": "Pick a destination account"
}
+222
View File
@@ -289,6 +289,228 @@ abstract class AppLocalizations {
/// In ru, this message translates to:
/// **'Продолжить'**
String get onboardingContinue;
/// No description provided for @txNewTitle.
///
/// In ru, this message translates to:
/// **'Новая операция'**
String get txNewTitle;
/// No description provided for @txEditTitle.
///
/// In ru, this message translates to:
/// **'Редактировать операцию'**
String get txEditTitle;
/// No description provided for @txTypeExpense.
///
/// In ru, this message translates to:
/// **'Расход'**
String get txTypeExpense;
/// No description provided for @txTypeIncome.
///
/// In ru, this message translates to:
/// **'Доход'**
String get txTypeIncome;
/// No description provided for @txTypeTransfer.
///
/// In ru, this message translates to:
/// **'Перевод'**
String get txTypeTransfer;
/// No description provided for @txAmountHint.
///
/// In ru, this message translates to:
/// **'0'**
String get txAmountHint;
/// No description provided for @txCategoryLabel.
///
/// In ru, this message translates to:
/// **'Категория'**
String get txCategoryLabel;
/// No description provided for @txAccountLabel.
///
/// In ru, this message translates to:
/// **'Счёт списания'**
String get txAccountLabel;
/// No description provided for @txAccountFromLabel.
///
/// In ru, this message translates to:
/// **'Откуда'**
String get txAccountFromLabel;
/// No description provided for @txAccountToLabel.
///
/// In ru, this message translates to:
/// **'Куда'**
String get txAccountToLabel;
/// No description provided for @txDateTimeLabel.
///
/// In ru, this message translates to:
/// **'Дата и время'**
String get txDateTimeLabel;
/// No description provided for @txBalanceAfter.
///
/// In ru, this message translates to:
/// **'Остаток после'**
String get txBalanceAfter;
/// No description provided for @txTransferAfter.
///
/// In ru, this message translates to:
/// **'После перевода'**
String get txTransferAfter;
/// No description provided for @txNoteLabel.
///
/// In ru, this message translates to:
/// **'Заметка'**
String get txNoteLabel;
/// No description provided for @txNoteHint.
///
/// In ru, this message translates to:
/// **'Например, продукты на неделю…'**
String get txNoteHint;
/// No description provided for @txSaveButton.
///
/// In ru, this message translates to:
/// **'Добавить транзакцию'**
String get txSaveButton;
/// No description provided for @txSaveEditButton.
///
/// In ru, this message translates to:
/// **'Сохранить'**
String get txSaveEditButton;
/// No description provided for @txTransferButton.
///
/// In ru, this message translates to:
/// **'Перевести {amount}'**
String txTransferButton(String amount);
/// No description provided for @txDeleteButton.
///
/// In ru, this message translates to:
/// **'Удалить'**
String get txDeleteButton;
/// No description provided for @txDeleteConfirmTitle.
///
/// In ru, this message translates to:
/// **'Удалить транзакцию?'**
String get txDeleteConfirmTitle;
/// No description provided for @txDeleteConfirmMessage.
///
/// In ru, this message translates to:
/// **'Действие нельзя отменить.'**
String get txDeleteConfirmMessage;
/// No description provided for @txDeleteConfirmYes.
///
/// In ru, this message translates to:
/// **'Удалить'**
String get txDeleteConfirmYes;
/// No description provided for @txDeleteConfirmNo.
///
/// In ru, this message translates to:
/// **'Отмена'**
String get txDeleteConfirmNo;
/// No description provided for @txPickCategory.
///
/// In ru, this message translates to:
/// **'Выберите категорию'**
String get txPickCategory;
/// No description provided for @txPickAccount.
///
/// In ru, this message translates to:
/// **'Выберите счёт'**
String get txPickAccount;
/// No description provided for @pickerAccountTitle.
///
/// In ru, this message translates to:
/// **'Счёт'**
String get pickerAccountTitle;
/// No description provided for @pickerAccountSubtitle.
///
/// In ru, this message translates to:
/// **'{count, plural, one{{count} счёт} few{{count} счёта} many{{count} счетов} other{{count} счетов}}'**
String pickerAccountSubtitle(int count);
/// No description provided for @pickerCategoryTitle.
///
/// In ru, this message translates to:
/// **'Категория'**
String get pickerCategoryTitle;
/// No description provided for @pickerCategorySubtitle.
///
/// In ru, this message translates to:
/// **'{count, plural, one{{count} категория} few{{count} категории} many{{count} категорий} other{{count} категорий}}'**
String pickerCategorySubtitle(int count);
/// No description provided for @pickerAddAccount.
///
/// In ru, this message translates to:
/// **'Добавить счёт'**
String get pickerAddAccount;
/// No description provided for @pickerCreateCategory.
///
/// In ru, this message translates to:
/// **'Создать категорию'**
String get pickerCreateCategory;
/// No description provided for @comingSoonShort.
///
/// In ru, this message translates to:
/// **'Скоро'**
String get comingSoonShort;
/// No description provided for @txValidationAmountRequired.
///
/// In ru, this message translates to:
/// **'Введите сумму'**
String get txValidationAmountRequired;
/// No description provided for @txValidationAccountRequired.
///
/// In ru, this message translates to:
/// **'Выберите счёт'**
String get txValidationAccountRequired;
/// No description provided for @txValidationCategoryRequired.
///
/// In ru, this message translates to:
/// **'Выберите категорию'**
String get txValidationCategoryRequired;
/// No description provided for @txValidationTransferSameAccount.
///
/// In ru, this message translates to:
/// **'Счёт-источник и счёт-получатель должны отличаться'**
String get txValidationTransferSameAccount;
/// No description provided for @txValidationTransferDestRequired.
///
/// In ru, this message translates to:
/// **'Выберите счёт получателя'**
String get txValidationTransferDestRequired;
}
class _AppLocalizationsDelegate
+130
View File
@@ -132,4 +132,134 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get onboardingContinue => 'Continue';
@override
String get txNewTitle => 'New transaction';
@override
String get txEditTitle => 'Edit transaction';
@override
String get txTypeExpense => 'Expense';
@override
String get txTypeIncome => 'Income';
@override
String get txTypeTransfer => 'Transfer';
@override
String get txAmountHint => '0';
@override
String get txCategoryLabel => 'Category';
@override
String get txAccountLabel => 'Account';
@override
String get txAccountFromLabel => 'From';
@override
String get txAccountToLabel => 'To';
@override
String get txDateTimeLabel => 'Date & time';
@override
String get txBalanceAfter => 'Balance after';
@override
String get txTransferAfter => 'After transfer';
@override
String get txNoteLabel => 'Note';
@override
String get txNoteHint => 'e.g. Groceries for the week…';
@override
String get txSaveButton => 'Add transaction';
@override
String get txSaveEditButton => 'Save changes';
@override
String txTransferButton(String amount) {
return 'Transfer $amount';
}
@override
String get txDeleteButton => 'Delete';
@override
String get txDeleteConfirmTitle => 'Delete transaction?';
@override
String get txDeleteConfirmMessage => 'This action cannot be undone.';
@override
String get txDeleteConfirmYes => 'Delete';
@override
String get txDeleteConfirmNo => 'Cancel';
@override
String get txPickCategory => 'Pick a category';
@override
String get txPickAccount => 'Pick an account';
@override
String get pickerAccountTitle => 'Account';
@override
String pickerAccountSubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count accounts',
one: '$count account',
);
return '$_temp0';
}
@override
String get pickerCategoryTitle => 'Category';
@override
String pickerCategorySubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count categories',
one: '$count category',
);
return '$_temp0';
}
@override
String get pickerAddAccount => 'Add account';
@override
String get pickerCreateCategory => 'Create category';
@override
String get comingSoonShort => 'Coming soon';
@override
String get txValidationAmountRequired => 'Enter an amount';
@override
String get txValidationAccountRequired => 'Pick an account';
@override
String get txValidationCategoryRequired => 'Pick a category';
@override
String get txValidationTransferSameAccount =>
'Source and destination must differ';
@override
String get txValidationTransferDestRequired => 'Pick a destination account';
}
+134
View File
@@ -138,4 +138,138 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get onboardingContinue => 'Продолжить';
@override
String get txNewTitle => 'Новая операция';
@override
String get txEditTitle => 'Редактировать операцию';
@override
String get txTypeExpense => 'Расход';
@override
String get txTypeIncome => 'Доход';
@override
String get txTypeTransfer => 'Перевод';
@override
String get txAmountHint => '0';
@override
String get txCategoryLabel => 'Категория';
@override
String get txAccountLabel => 'Счёт списания';
@override
String get txAccountFromLabel => 'Откуда';
@override
String get txAccountToLabel => 'Куда';
@override
String get txDateTimeLabel => 'Дата и время';
@override
String get txBalanceAfter => 'Остаток после';
@override
String get txTransferAfter => 'После перевода';
@override
String get txNoteLabel => 'Заметка';
@override
String get txNoteHint => 'Например, продукты на неделю…';
@override
String get txSaveButton => 'Добавить транзакцию';
@override
String get txSaveEditButton => 'Сохранить';
@override
String txTransferButton(String amount) {
return 'Перевести $amount';
}
@override
String get txDeleteButton => 'Удалить';
@override
String get txDeleteConfirmTitle => 'Удалить транзакцию?';
@override
String get txDeleteConfirmMessage => 'Действие нельзя отменить.';
@override
String get txDeleteConfirmYes => 'Удалить';
@override
String get txDeleteConfirmNo => 'Отмена';
@override
String get txPickCategory => 'Выберите категорию';
@override
String get txPickAccount => 'Выберите счёт';
@override
String get pickerAccountTitle => 'Счёт';
@override
String pickerAccountSubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count счетов',
many: '$count счетов',
few: '$count счёта',
one: '$count счёт',
);
return '$_temp0';
}
@override
String get pickerCategoryTitle => 'Категория';
@override
String pickerCategorySubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count категорий',
many: '$count категорий',
few: '$count категории',
one: '$count категория',
);
return '$_temp0';
}
@override
String get pickerAddAccount => 'Добавить счёт';
@override
String get pickerCreateCategory => 'Создать категорию';
@override
String get comingSoonShort => 'Скоро';
@override
String get txValidationAmountRequired => 'Введите сумму';
@override
String get txValidationAccountRequired => 'Выберите счёт';
@override
String get txValidationCategoryRequired => 'Выберите категорию';
@override
String get txValidationTransferSameAccount =>
'Счёт-источник и счёт-получатель должны отличаться';
@override
String get txValidationTransferDestRequired => 'Выберите счёт получателя';
}
+54 -1
View File
@@ -80,5 +80,58 @@
"onboardingSubtitle": "Расскажите, как к вам обращаться.",
"onboardingNameLabel": "Ваше имя",
"onboardingNameHint": "Например, Алекс",
"onboardingContinue": "Продолжить"
"onboardingContinue": "Продолжить",
"txNewTitle": "Новая операция",
"txEditTitle": "Редактировать операцию",
"txTypeExpense": "Расход",
"txTypeIncome": "Доход",
"txTypeTransfer": "Перевод",
"txAmountHint": "0",
"txCategoryLabel": "Категория",
"txAccountLabel": "Счёт списания",
"txAccountFromLabel": "Откуда",
"txAccountToLabel": "Куда",
"txDateTimeLabel": "Дата и время",
"txBalanceAfter": "Остаток после",
"txTransferAfter": "После перевода",
"txNoteLabel": "Заметка",
"txNoteHint": "Например, продукты на неделю…",
"txSaveButton": "Добавить транзакцию",
"txSaveEditButton": "Сохранить",
"txTransferButton": "Перевести {amount}",
"@txTransferButton": {
"placeholders": {
"amount": { "type": "String" }
}
},
"txDeleteButton": "Удалить",
"txDeleteConfirmTitle": "Удалить транзакцию?",
"txDeleteConfirmMessage": "Действие нельзя отменить.",
"txDeleteConfirmYes": "Удалить",
"txDeleteConfirmNo": "Отмена",
"txPickCategory": "Выберите категорию",
"txPickAccount": "Выберите счёт",
"pickerAccountTitle": "Счёт",
"pickerAccountSubtitle": "{count, plural, one{{count} счёт} few{{count} счёта} many{{count} счетов} other{{count} счетов}}",
"@pickerAccountSubtitle": {
"placeholders": {
"count": { "type": "int" }
}
},
"pickerCategoryTitle": "Категория",
"pickerCategorySubtitle": "{count, plural, one{{count} категория} few{{count} категории} many{{count} категорий} other{{count} категорий}}",
"@pickerCategorySubtitle": {
"placeholders": {
"count": { "type": "int" }
}
},
"pickerAddAccount": "Добавить счёт",
"pickerCreateCategory": "Создать категорию",
"comingSoonShort": "Скоро",
"txValidationAmountRequired": "Введите сумму",
"txValidationAccountRequired": "Выберите счёт",
"txValidationCategoryRequired": "Выберите категорию",
"txValidationTransferSameAccount": "Счёт-источник и счёт-получатель должны отличаться",
"txValidationTransferDestRequired": "Выберите счёт получателя"
}
+29
View File
@@ -1,11 +1,40 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'src/app/app.dart';
import 'src/core/logging/app_logger.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Глобальные обработчики ошибок — гарантируют, что НИ ОДНА ошибка
// (синхронная Flutter, asyncronous, native) не уйдёт молча.
FlutterError.onError = (FlutterErrorDetails details) {
AppLogger.error(
'FlutterError: ${details.exceptionAsString()}',
error: details.exception,
stackTrace: details.stack,
tag: details.library ?? 'flutter',
);
// Не подавляем штатное поведение Flutter (red-screen в debug,
// `dumpErrorToConsole` и т.п.) — оно по-прежнему срабатывает.
FlutterError.presentError(details);
};
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
AppLogger.error(
'PlatformDispatcher uncaught: $error',
error: error,
stackTrace: stack,
tag: 'platform',
);
// true = ошибка обработана; иначе она «всплывёт» дальше.
return true;
};
await initializeDateFormatting('ru');
runApp(const ProviderScope(child: NewBudgetApp()));
}
+37 -1
View File
@@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -6,6 +6,7 @@ import '../../features/accounts/presentation/screens/accounts_screen.dart';
import '../../features/analytics/presentation/screens/analytics_screen.dart';
import '../../features/home/presentation/screens/home_screen.dart';
import '../../features/profile/presentation/screens/profile_screen.dart';
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
import '../../features/user/application/active_user_controller.dart';
import '../../features/user/presentation/screens/onboarding_screen.dart';
import '../../shared/widgets/app_scaffold.dart';
@@ -42,6 +43,20 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.onboarding,
builder: (context, state) => const OnboardingScreen(),
),
GoRoute(
path: AppRoutes.transactionNew,
pageBuilder: (context, state) => _slideUpPage(
state,
const TransactionFormScreen(),
),
),
GoRoute(
path: AppRoutes.transactionEditPattern,
pageBuilder: (context, state) => _slideUpPage(
state,
TransactionFormScreen(txId: state.pathParameters['id']),
),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppScaffold(
navigationShell: navigationShell,
@@ -84,3 +99,24 @@ GoRouter appRouter(Ref ref) {
],
);
}
Page<void> _slideUpPage(GoRouterState state, Widget child) {
return CustomTransitionPage<void>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(curved),
child: child,
);
},
);
}
+4
View File
@@ -6,4 +6,8 @@ class AppRoutes {
static const accounts = '/accounts';
static const profile = '/profile';
static const onboarding = '/onboarding';
static const transactionNew = '/transactions/new';
static const transactionEditPattern = '/transactions/edit/:id';
static String transactionEdit(String id) => '/transactions/edit/$id';
}
+27 -6
View File
@@ -36,12 +36,33 @@ class AccountsDao extends DatabaseAccessor<AppDatabase>
..where((t) => t.id.equals(id)))
.write(const AccountsTableCompanion(archived: Value(true)));
/// Реактивный текущий баланс счёта (начальный + сумма транзакций).
/// TODO: добавить сложную SQL-агрегацию с учётом типа транзакции.
/// Реактивный текущий баланс счёта.
///
/// Формула: `initialBalance + Σincome Σexpense + Σtransfer_in Σtransfer_out`,
/// где transfer_in — переводы НА этот счёт (transferToAccountId = accountId),
/// transfer_out — переводы С этого счёта (accountId = accountId, type=transfer).
Stream<int> watchAccountBalance(String accountId) {
// Stub: возвращает только initialBalance пока не реализована агрегация.
return (select(accountsTable)..where((t) => t.id.equals(accountId)))
.watchSingleOrNull()
.map((a) => a?.initialBalance ?? 0);
final query = customSelect(
'''
SELECT
COALESCE((SELECT initial_balance FROM accounts WHERE id = ?1), 0)
+ COALESCE((
SELECT SUM(CASE
WHEN type = 'income' AND account_id = ?1 THEN amount
WHEN type = 'expense' AND account_id = ?1 THEN -amount
WHEN type = 'transfer' AND account_id = ?1 THEN -amount
WHEN type = 'transfer' AND transfer_to_account_id = ?1 THEN amount
ELSE 0
END)
FROM transactions
WHERE account_id = ?1 OR transfer_to_account_id = ?1
), 0) AS balance
''',
variables: [Variable<String>(accountId)],
readsFrom: {accountsTable, transactionsTable},
);
return query.watch().map(
(rows) => rows.isEmpty ? 0 : (rows.first.data['balance'] as int? ?? 0),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:developer' as developer;
/// Единая точка логирования приложения.
///
/// Поверх `dart:developer.log` — видно в IDE-консоли (Run/Debug) и в
/// DevTools (вкладка Logging). Сообщения помечены тэгом для фильтрации
/// и содержат уровень severity (см. https://api.dart.dev/stable/dart-developer/log.html).
///
/// Используется как глобальным `FlutterError.onError` / `PlatformDispatcher.onError`,
/// так и явно в `try/catch` блоках UI/контроллеров.
class AppLogger {
AppLogger._();
static const String _defaultTag = 'NewBudget';
// Severity-уровни — те же, что использует пакет logging.
static const int _levelInfo = 800;
static const int _levelWarning = 900;
static const int _levelError = 1000;
static void info(String message, {String tag = _defaultTag}) {
developer.log(message, name: tag, level: _levelInfo);
}
static void warning(
String message, {
Object? error,
StackTrace? stackTrace,
String tag = _defaultTag,
}) {
developer.log(
message,
name: tag,
level: _levelWarning,
error: error,
stackTrace: stackTrace,
);
}
static void error(
String message, {
Object? error,
StackTrace? stackTrace,
String tag = _defaultTag,
}) {
developer.log(
message,
name: tag,
level: _levelError,
error: error,
stackTrace: stackTrace,
);
}
}
@@ -16,7 +16,7 @@ Stream<int> accountBalance(Ref ref, String accountId) =>
ref.watch(accountRepositoryProvider).watchBalance(accountId);
/// Контроллер CRUD-операций над счетами.
@riverpod
@Riverpod(keepAlive: true)
class AccountsController extends _$AccountsController {
@override
AsyncValue<void> build() => const AsyncData(null);
@@ -20,7 +20,7 @@ Stream<List<Category>> categoriesByTypeStream(
ref.watch(categoryRepositoryProvider).watchByType(userId, type);
/// Контроллер CRUD-операций над категориями.
@riverpod
@Riverpod(keepAlive: true)
class CategoriesController extends _$CategoriesController {
@override
AsyncValue<void> build() => const AsyncData(null);
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../transactions/domain/entities/transaction.dart';
@@ -58,6 +62,9 @@ class _HomeContent extends ConsumerWidget {
ref.watch(categoriesStreamProvider(userId)).value ??
const <Category>[];
final categoryById = {for (final c in categories) c.id: c};
final accounts =
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
final txs = ref.watch(filteredTransactionsProvider(userId));
final groups = _groupByDay(txs, locale, l10n);
@@ -80,6 +87,7 @@ class _HomeContent extends ConsumerWidget {
(context, i) => _DayGroupBlock(
group: groups[i],
categoryById: categoryById,
accountById: accountById,
),
childCount: groups.length,
),
@@ -91,7 +99,9 @@ class _HomeContent extends ConsumerWidget {
Positioned(
right: 16,
bottom: 16,
child: FabAddTransaction(onPressed: () {}),
child: FabAddTransaction(
onPressed: () => context.push(AppRoutes.transactionNew),
),
),
],
);
@@ -99,10 +109,15 @@ class _HomeContent extends ConsumerWidget {
}
class _DayGroupBlock extends StatelessWidget {
const _DayGroupBlock({required this.group, required this.categoryById});
const _DayGroupBlock({
required this.group,
required this.categoryById,
required this.accountById,
});
final _DayGroup group;
final Map<String, Category> categoryById;
final Map<String, Account> accountById;
@override
Widget build(BuildContext context) {
@@ -110,7 +125,11 @@ class _DayGroupBlock extends StatelessWidget {
children: [
DayHeader(label: group.label, totalMinor: group.totalSpentMinor),
for (final t in group.items)
TxRow(tx: t, category: categoryById[t.categoryId]),
TxRow(
tx: t,
category: categoryById[t.categoryId],
accountById: accountById,
),
],
);
}
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
import '../../../transactions/domain/entities/transaction.dart';
@@ -13,75 +17,103 @@ class TxRow extends StatelessWidget {
super.key,
required this.tx,
required this.category,
required this.accountById,
});
final Transaction tx;
final Category? category;
final Map<String, Account> accountById;
@override
Widget build(BuildContext context) {
final p = context.palette;
final cat = category;
final color = cat != null ? colorForCategory(cat) : p.ink2;
final icon = cat != null ? iconForCategory(cat) : Icons.more_horiz;
final isTransfer = tx.type == TransactionType.transfer;
final signedAmount = tx.type == TransactionType.income
? tx.amount
: tx.type == TransactionType.expense
? -tx.amount
: 0;
final amountColor =
tx.type == TransactionType.income ? p.positive : p.ink;
final Color iconColor;
final IconData iconData;
final String title;
final String subtitle;
final int amountToShow;
final Color amountColor;
final bool withSign;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: p.line)),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
if (isTransfer) {
final from = accountById[tx.accountId];
final to = accountById[tx.transferToAccountId];
iconColor = p.ink2;
iconData = Icons.swap_horiz;
title = '${from?.name ?? ''}${to?.name ?? ''}';
subtitle =
'${context.l10n.txTypeTransfer} · ${_subtitleTime(tx.date)}';
amountToShow = tx.amount;
amountColor = p.ink;
withSign = false;
} else {
final cat = category;
iconColor = cat != null ? colorForCategory(cat) : p.ink2;
iconData = cat != null ? iconForCategory(cat) : Icons.more_horiz;
title = tx.note ?? '';
subtitle = '${cat?.name ?? ''} · ${_subtitleTime(tx.date)}';
amountToShow = tx.type == TransactionType.income
? tx.amount
: -tx.amount;
amountColor =
tx.type == TransactionType.income ? p.positive : p.ink;
withSign = true;
}
return InkWell(
onTap: () => context.push(AppRoutes.transactionEdit(tx.id)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: p.line)),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconData, size: 18, color: iconColor),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tx.note ?? '',
style: TextStyle(
fontSize: 14,
color: p.ink,
fontWeight: FontWeight.w500,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
color: p.ink,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'${cat?.name ?? ''} · ${_subtitleTime(tx.date)}',
style: TextStyle(fontSize: 11, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(fontSize: 11, color: p.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
const SizedBox(width: 8),
MoneyText(
signedAmount,
color: amountColor,
fontSize: 14,
withSign: true,
),
],
const SizedBox(width: 8),
MoneyText(
amountToShow,
color: amountColor,
fontSize: 14,
withSign: withSign,
),
],
),
),
);
}
@@ -16,7 +16,7 @@ part 'settings_controller.g.dart';
/// // Обновление
/// ref.read(settingsControllerProvider(userId).notifier).setThemeMode(AppThemeMode.dark);
/// ```
@riverpod
@Riverpod(keepAlive: true)
class SettingsController extends _$SettingsController {
@override
Future<Settings> build(String userId) async {
@@ -32,6 +32,11 @@ Stream<List<Transaction>> transactionsStream(
to: to,
);
/// Загружает одну транзакцию по id (для экрана редактирования).
@riverpod
Future<Transaction?> transactionById(Ref ref, String id) =>
ref.watch(transactionRepositoryProvider).findById(id);
// ---------------------------------------------------------------------------
// Мутации (CRUD)
// ---------------------------------------------------------------------------
@@ -42,7 +47,7 @@ Stream<List<Transaction>> transactionsStream(
/// - [AsyncData] — операция завершена (или не начата);
/// - [AsyncLoading] — выполняется;
/// - [AsyncError] — ошибка.
@riverpod
@Riverpod(keepAlive: true)
class TransactionsController extends _$TransactionsController {
@override
AsyncValue<void> build() => const AsyncData(null);
@@ -62,8 +67,8 @@ class TransactionsController extends _$TransactionsController {
String? transferToAccountId,
}) async {
state = const AsyncLoading();
final result = await AsyncValue.guard(
() => ref.read(transactionRepositoryProvider).create(
try {
final tx = await ref.read(transactionRepositoryProvider).create(
userId: userId,
accountId: accountId,
categoryId: categoryId,
@@ -72,31 +77,37 @@ class TransactionsController extends _$TransactionsController {
date: date,
note: note,
transferToAccountId: transferToAccountId,
),
);
state = result.hasError
? AsyncError(result.error!, StackTrace.current)
: const AsyncData(null);
return result.value!;
);
state = const AsyncData(null);
return tx;
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
/// Обновляет существующую транзакцию и возвращает актуальную сущность.
Future<Transaction> updateTransaction(Transaction transaction) async {
state = const AsyncLoading();
final result = await AsyncValue.guard(
() => ref.read(transactionRepositoryProvider).update(transaction),
);
state = result.hasError
? AsyncError(result.error!, StackTrace.current)
: const AsyncData(null);
return result.value!;
try {
final tx = await ref.read(transactionRepositoryProvider).update(transaction);
state = const AsyncData(null);
return tx;
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
/// Удаляет транзакцию по [id].
Future<void> deleteTransaction(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(transactionRepositoryProvider).delete(id),
).then((_) => const AsyncData(null));
try {
await ref.read(transactionRepositoryProvider).delete(id);
state = const AsyncData(null);
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
}
@@ -0,0 +1,860 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../../core/logging/app_logger.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../accounts/presentation/widgets/account_icon.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
import '../../../home/presentation/widgets/money_text.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/transaction_providers.dart';
import '../../application/transactions_controller.dart';
import '../../domain/entities/transaction.dart';
import '../state/transaction_draft.dart';
import '../widgets/account_picker_sheet.dart';
import '../widgets/amount_input.dart';
import '../widgets/balance_preview.dart';
import '../widgets/category_picker_sheet.dart';
import '../widgets/date_time_field.dart';
import '../widgets/transfer_account_row.dart';
import '../widgets/type_segmented.dart';
class TransactionFormScreen extends ConsumerWidget {
const TransactionFormScreen({super.key, this.txId});
/// `null` → создание новой; иначе — редактирование.
final String? txId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final activeUser = ref.watch(activeUserControllerProvider);
return Scaffold(
backgroundColor: p.paper,
body: activeUser.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (user) {
if (user == null) return const SizedBox.shrink();
if (txId == null) {
return _FormBody(userId: user.id, txId: null);
}
// Edit-режим: ждём загрузку транзакции и гидратируем черновик.
final txAsync = ref.watch(transactionByIdProvider(txId!));
return txAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (tx) {
if (tx == null) {
return Center(child: Text('', style: TextStyle(color: p.ink2)));
}
return _EditHydrator(userId: user.id, tx: tx);
},
);
},
),
);
}
}
/// Заполняет черновик данными транзакции один раз при первом построении.
class _EditHydrator extends ConsumerStatefulWidget {
const _EditHydrator({required this.userId, required this.tx});
final String userId;
final Transaction tx;
@override
ConsumerState<_EditHydrator> createState() => _EditHydratorState();
}
class _EditHydratorState extends ConsumerState<_EditHydrator> {
bool _hydrated = false;
@override
Widget build(BuildContext context) {
if (!_hydrated) {
_hydrated = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref
.read(transactionDraftControllerProvider(widget.tx.id).notifier)
.hydrate(TransactionDraft(
type: widget.tx.type,
amountMinor: widget.tx.amount,
date: widget.tx.date,
accountId: widget.tx.accountId,
categoryId: widget.tx.categoryId,
transferToAccountId: widget.tx.transferToAccountId,
note: widget.tx.note,
));
});
}
return _FormBody(userId: widget.userId, txId: widget.tx.id);
}
}
class _FormBody extends ConsumerStatefulWidget {
const _FormBody({required this.userId, required this.txId});
final String userId;
final String? txId;
@override
ConsumerState<_FormBody> createState() => _FormBodyState();
}
class _FormBodyState extends ConsumerState<_FormBody> {
late final TextEditingController _amountCtrl;
late final TextEditingController _noteCtrl;
bool _submitting = false;
String? _amountError;
bool _amountSynced = false;
bool _noteSynced = false;
@override
void initState() {
super.initState();
_amountCtrl = TextEditingController();
_noteCtrl = TextEditingController();
}
@override
void dispose() {
_amountCtrl.dispose();
_noteCtrl.dispose();
super.dispose();
}
void _syncControllersFromDraft(TransactionDraft draft) {
if (!_amountSynced && draft.amountMinor > 0) {
_amountSynced = true;
final whole = draft.amountMinor ~/ 100;
final frac = draft.amountMinor % 100;
_amountCtrl.text =
frac == 0 ? '$whole' : '$whole.${frac.toString().padLeft(2, '0')}';
}
if (!_noteSynced && (draft.note?.isNotEmpty ?? false)) {
_noteSynced = true;
_noteCtrl.text = draft.note!;
}
}
Future<void> _save() async {
final l10n = context.l10n;
final draft = ref.read(transactionDraftControllerProvider(widget.txId));
if (draft.amountMinor <= 0) {
setState(() => _amountError = l10n.txValidationAmountRequired);
return;
}
if (draft.accountId == null) {
_snack(l10n.txValidationAccountRequired);
return;
}
if (draft.type == TransactionType.transfer) {
if (draft.transferToAccountId == null) {
_snack(l10n.txValidationTransferDestRequired);
return;
}
if (draft.transferToAccountId == draft.accountId) {
_snack(l10n.txValidationTransferSameAccount);
return;
}
} else {
if (draft.categoryId == null) {
_snack(l10n.txValidationCategoryRequired);
return;
}
}
setState(() {
_submitting = true;
_amountError = null;
});
final controller = ref.read(transactionsControllerProvider.notifier);
try {
if (widget.txId == null) {
await controller.createTransaction(
userId: widget.userId,
accountId: draft.accountId!,
categoryId: draft.type == TransactionType.transfer ? null : draft.categoryId,
type: draft.type,
amount: draft.amountMinor,
date: draft.date,
note: draft.note?.trim().isEmpty ?? true ? null : draft.note!.trim(),
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
: null,
);
} else {
final existing = await ref
.read(transactionRepositoryProvider)
.findById(widget.txId!);
if (existing == null) return;
await controller.updateTransaction(existing.copyWith(
accountId: draft.accountId!,
categoryId:
draft.type == TransactionType.transfer ? null : draft.categoryId,
type: draft.type,
amount: draft.amountMinor,
date: draft.date,
note: draft.note?.trim().isEmpty ?? true ? null : draft.note!.trim(),
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
: null,
));
}
if (!mounted) return;
Navigator.of(context).pop();
} catch (e, st) {
AppLogger.error(
widget.txId == null
? 'Failed to create transaction'
: 'Failed to update transaction (id=${widget.txId})',
error: e,
stackTrace: st,
tag: 'transaction_form',
);
if (!mounted) return;
_snack('$e');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _delete() async {
final l10n = context.l10n;
final id = widget.txId;
if (id == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.txDeleteConfirmTitle),
content: Text(l10n.txDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l10n.txDeleteConfirmNo),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l10n.txDeleteConfirmYes),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _submitting = true);
try {
await ref
.read(transactionsControllerProvider.notifier)
.deleteTransaction(id);
if (!mounted) return;
Navigator.of(context).pop();
} catch (e, st) {
AppLogger.error(
'Failed to delete transaction (id=$id)',
error: e,
stackTrace: st,
tag: 'transaction_form',
);
if (!mounted) return;
_snack('$e');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _snack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final draft = ref.watch(transactionDraftControllerProvider(widget.txId));
final draftCtrl =
ref.read(transactionDraftControllerProvider(widget.txId).notifier);
_syncControllersFromDraft(draft);
final accounts = ref.watch(accountsStreamProvider(widget.userId)).value ??
const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
final isEdit = widget.txId != null;
return SafeArea(
child: Column(
children: [
_Header(
title: isEdit ? l10n.txEditTitle : l10n.txNewTitle,
onClose: () => Navigator.of(context).pop(),
trailing: isEdit
? IconButton(
onPressed: _submitting ? null : _delete,
icon: Icon(Icons.delete_outline, color: p.negative),
tooltip: l10n.txDeleteButton,
)
: null,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TypeSegmented(
value: draft.type,
onChanged: (t) {
draftCtrl.setType(t);
setState(() {});
},
),
const SizedBox(height: 20),
_Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AmountInput(
controller: _amountCtrl,
onChanged: (minor) {
draftCtrl.setAmount(minor);
if (_amountError != null && minor > 0) {
setState(() => _amountError = null);
}
},
),
if (_amountError != null) ...[
const SizedBox(height: 6),
Text(
_amountError!,
style:
TextStyle(fontSize: 12, color: p.negative),
),
],
],
),
),
),
const SizedBox(height: 12),
if (draft.type == TransactionType.transfer)
_TransferBody(
userId: widget.userId,
txId: widget.txId,
draft: draft,
accountById: accountById,
)
else
_ExpenseIncomeBody(
userId: widget.userId,
txId: widget.txId,
draft: draft,
accountById: accountById,
),
const SizedBox(height: 16),
_Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.txNoteLabel,
style: TextStyle(fontSize: 12, color: p.ink2),
),
TextField(
controller: _noteCtrl,
onChanged: (v) => draftCtrl.setNote(v),
maxLines: 3,
minLines: 1,
style: TextStyle(fontSize: 14, color: p.ink),
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
border: InputBorder.none,
hintText: l10n.txNoteHint,
hintStyle:
TextStyle(fontSize: 14, color: p.ink2),
),
),
],
),
),
),
const SizedBox(height: 24),
_SubmitButton(
label: _submitLabel(l10n, draft, isEdit),
loading: _submitting,
onPressed: _submitting ? null : _save,
),
],
),
),
),
],
),
);
}
String _submitLabel(
AppLocalizations l10n,
TransactionDraft draft,
bool isEdit,
) {
if (isEdit) return l10n.txSaveEditButton;
if (draft.type == TransactionType.transfer && draft.amountMinor > 0) {
return l10n.txTransferButton(formatMinor(draft.amountMinor));
}
return l10n.txSaveButton;
}
}
class _Header extends StatelessWidget {
const _Header({required this.title, required this.onClose, this.trailing});
final String title;
final VoidCallback onClose;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
IconButton(
onPressed: onClose,
icon: Icon(Icons.close, color: p.ink),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: p.ink,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 48, child: trailing),
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Container(
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line),
),
child: child,
);
}
}
class _SubmitButton extends StatelessWidget {
const _SubmitButton({
required this.label,
required this.loading,
required this.onPressed,
});
final String label;
final bool loading;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final p = context.palette;
return SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: p.accent,
foregroundColor: p.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: onPressed,
child: loading
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: p.paper,
),
)
: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _ExpenseIncomeBody extends ConsumerWidget {
const _ExpenseIncomeBody({
required this.userId,
required this.txId,
required this.draft,
required this.accountById,
});
final String userId;
final String? txId;
final TransactionDraft draft;
final Map<String, Account> accountById;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final draftCtrl =
ref.read(transactionDraftControllerProvider(txId).notifier);
final categoryType = draft.type == TransactionType.income
? CategoryType.income
: CategoryType.expense;
final categoriesAsync =
ref.watch(categoriesByTypeStreamProvider(userId, categoryType));
final categoryById = {
for (final c in (categoriesAsync.value ?? const <Category>[])) c.id: c,
};
final selectedCategory =
draft.categoryId == null ? null : categoryById[draft.categoryId!];
final selectedAccount =
draft.accountId == null ? null : accountById[draft.accountId!];
final delta = draft.type == TransactionType.income
? draft.amountMinor
: -draft.amountMinor;
return _Card(
child: Column(
children: [
_PickerRow(
label: l10n.txCategoryLabel,
leading: selectedCategory != null
? _CategoryPill(category: selectedCategory)
: null,
placeholder: l10n.txPickCategory,
onTap: () async {
final id = await showCategoryPicker(
context,
userId: userId,
type: categoryType,
currentCategoryId: draft.categoryId,
);
if (id != null) draftCtrl.setCategory(id);
},
),
_Divider(),
_PickerRow(
label: l10n.txAccountLabel,
leading: selectedAccount != null
? _AccountPill(account: selectedAccount)
: null,
placeholder: l10n.txPickAccount,
onTap: () async {
final id = await showAccountPicker(
context,
userId: userId,
currentAccountId: draft.accountId,
);
if (id != null) draftCtrl.setAccount(id);
},
),
_Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.txDateTimeLabel,
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(height: 6),
DateTimeField(
value: draft.date,
onChanged: draftCtrl.setDate,
),
],
),
),
if (selectedAccount != null) ...[
_Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: BalanceAfterPreview(
accountId: selectedAccount.id,
delta: delta,
),
),
],
],
),
);
}
}
class _TransferBody extends ConsumerWidget {
const _TransferBody({
required this.userId,
required this.txId,
required this.draft,
required this.accountById,
});
final String userId;
final String? txId;
final TransactionDraft draft;
final Map<String, Account> accountById;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final draftCtrl =
ref.read(transactionDraftControllerProvider(txId).notifier);
final from = draft.accountId == null ? null : accountById[draft.accountId!];
final to = draft.transferToAccountId == null
? null
: accountById[draft.transferToAccountId!];
return _Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TransferAccountRow(
label: l10n.txAccountFromLabel,
account: from,
hint: l10n.txPickAccount,
onTap: () async {
final id = await showAccountPicker(
context,
userId: userId,
excludeAccountId: draft.transferToAccountId,
currentAccountId: draft.accountId,
);
if (id != null) draftCtrl.setAccount(id);
},
),
),
_Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TransferAccountRow(
label: l10n.txAccountToLabel,
account: to,
hint: l10n.txPickAccount,
onTap: () async {
final id = await showAccountPicker(
context,
userId: userId,
excludeAccountId: draft.accountId,
currentAccountId: draft.transferToAccountId,
);
if (id != null) draftCtrl.setTransferToAccount(id);
},
),
),
_Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.txDateTimeLabel,
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(height: 6),
DateTimeField(
value: draft.date,
onChanged: draftCtrl.setDate,
),
],
),
),
if (from != null && to != null && draft.amountMinor > 0) ...[
_Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: TransferAfterPreview(
fromAccountId: from.id,
toAccountId: to.id,
amountMinor: draft.amountMinor,
),
),
],
],
),
);
}
}
class _Divider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16),
color: context.palette.line,
);
}
}
class _PickerRow extends StatelessWidget {
const _PickerRow({
required this.label,
required this.placeholder,
required this.onTap,
this.leading,
});
final String label;
final String placeholder;
final VoidCallback onTap;
final Widget? leading;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: p.ink2)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: leading ??
Text(
placeholder,
style: TextStyle(
fontSize: 16,
color: p.ink2,
fontWeight: FontWeight.w500,
),
),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
],
),
),
);
}
}
class _CategoryPill extends StatelessWidget {
const _CategoryPill({required this.category});
final Category category;
@override
Widget build(BuildContext context) {
final p = context.palette;
final color = colorForCategory(category);
return Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconForCategory(category), size: 16, color: color),
),
const SizedBox(width: 10),
Flexible(
child: Text(
category.name,
style: TextStyle(
fontSize: 16,
color: p.ink,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class _AccountPill extends StatelessWidget {
const _AccountPill({required this.account});
final Account account;
@override
Widget build(BuildContext context) {
final p = context.palette;
final color = Color(account.colorValue ?? 0xFFB8B5AC);
return Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconForAccount(account), size: 16, color: color),
),
const SizedBox(width: 10),
Flexible(
child: Text(
account.name,
style: TextStyle(
fontSize: 16,
color: p.ink,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../core/database/converters/enum_converters.dart';
part 'transaction_draft.g.dart';
/// Черновик формы транзакции.
///
/// Хранит локальное состояние экрана `TransactionFormScreen` —
/// никаких записей в БД до явного «Сохранить».
@immutable
class TransactionDraft {
const TransactionDraft({
required this.type,
required this.amountMinor,
required this.date,
this.accountId,
this.categoryId,
this.transferToAccountId,
this.note,
});
final TransactionType type;
final int amountMinor;
final DateTime date;
final String? accountId;
final String? categoryId;
final String? transferToAccountId;
final String? note;
TransactionDraft copyWith({
TransactionType? type,
int? amountMinor,
DateTime? date,
Object? accountId = _sentinel,
Object? categoryId = _sentinel,
Object? transferToAccountId = _sentinel,
Object? note = _sentinel,
}) {
return TransactionDraft(
type: type ?? this.type,
amountMinor: amountMinor ?? this.amountMinor,
date: date ?? this.date,
accountId:
identical(accountId, _sentinel) ? this.accountId : accountId as String?,
categoryId: identical(categoryId, _sentinel)
? this.categoryId
: categoryId as String?,
transferToAccountId: identical(transferToAccountId, _sentinel)
? this.transferToAccountId
: transferToAccountId as String?,
note: identical(note, _sentinel) ? this.note : note as String?,
);
}
static const _sentinel = Object();
}
/// Notifier-черновик одной формы. `txId == null` означает создание новой.
@riverpod
class TransactionDraftController extends _$TransactionDraftController {
@override
TransactionDraft build(String? txId) {
return TransactionDraft(
type: TransactionType.expense,
amountMinor: 0,
date: DateTime.now(),
);
}
void setType(TransactionType type) {
final next = state.copyWith(type: type);
// При смене типа категорию сбрасываем — она привязана к типу.
state = type == TransactionType.transfer
? next.copyWith(categoryId: null)
: next.copyWith(categoryId: null);
}
void setAmount(int amountMinor) =>
state = state.copyWith(amountMinor: amountMinor);
void setDate(DateTime date) => state = state.copyWith(date: date);
void setAccount(String? accountId) =>
state = state.copyWith(accountId: accountId);
void setCategory(String? categoryId) =>
state = state.copyWith(categoryId: categoryId);
void setTransferToAccount(String? accountId) =>
state = state.copyWith(transferToAccountId: accountId);
void setNote(String? note) => state = state.copyWith(note: note);
void hydrate(TransactionDraft draft) => state = draft;
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../accounts/presentation/widgets/account_icon.dart';
import '../../../home/presentation/widgets/money_text.dart';
/// Открывает модальный лист выбора счёта. Возвращает `id` выбранного счёта
/// или `null` при отмене.
Future<String?> showAccountPicker(
BuildContext context, {
required String userId,
String? excludeAccountId,
String? currentAccountId,
}) {
return showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _AccountPickerSheet(
userId: userId,
excludeAccountId: excludeAccountId,
currentAccountId: currentAccountId,
),
);
}
class _AccountPickerSheet extends ConsumerWidget {
const _AccountPickerSheet({
required this.userId,
this.excludeAccountId,
this.currentAccountId,
});
final String userId;
final String? excludeAccountId;
final String? currentAccountId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final accountsAsync = ref.watch(accountsStreamProvider(userId));
final accounts =
(accountsAsync.value ?? const <Account>[]).where((a) => a.id != excludeAccountId).toList();
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, controller) => Container(
decoration: BoxDecoration(
color: p.paper,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: p.line2,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
l10n.pickerAccountTitle,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
const Spacer(),
Text(
l10n.pickerAccountSubtitle(accounts.length),
style: TextStyle(fontSize: 12, color: p.ink2),
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: ListView.builder(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: accounts.length + 1,
itemBuilder: (context, i) {
if (i == accounts.length) {
return _AddAccountTile(
label: l10n.pickerAddAccount,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.comingSoonShort)),
);
},
);
}
final a = accounts[i];
return _AccountTile(
account: a,
selected: a.id == currentAccountId,
onTap: () => Navigator.of(context).pop(a.id),
);
},
),
),
],
),
),
);
}
}
class _AccountTile extends ConsumerWidget {
const _AccountTile({
required this.account,
required this.selected,
required this.onTap,
});
final Account account;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final balance = ref.watch(accountBalanceProvider(account.id)).value ?? 0;
final color = Color(account.colorValue ?? 0xFFB8B5AC);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Material(
color: selected ? p.accentSoft : p.paper2,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(iconForAccount(account), size: 20, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
const SizedBox(height: 2),
Text(
shortAccountLabel(account),
style: TextStyle(fontSize: 12, color: p.ink2),
),
],
),
),
MoneyText(
balance,
color: p.ink,
fontSize: 14,
fontWeight: FontWeight.w600,
),
],
),
),
),
),
);
}
}
class _AddAccountTile extends StatelessWidget {
const _AddAccountTile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line, style: BorderStyle.solid),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: p.accentSoft,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.add, color: p.accent, size: 20),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 15,
color: p.ink,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_theme.dart';
/// Парсит ввод пользователя в минорные единицы (копейки).
///
/// Поддерживает «,» и «.» как разделитель, до 2 знаков после.
/// Возвращает 0 для пустой/невалидной строки.
int parseAmountToMinor(String raw) {
final normalized = raw.replaceAll(',', '.').replaceAll(' ', '').trim();
if (normalized.isEmpty) return 0;
final parts = normalized.split('.');
if (parts.length > 2) return 0;
final intPart = int.tryParse(parts[0].isEmpty ? '0' : parts[0]);
if (intPart == null) return 0;
if (parts.length == 1) return intPart * 100;
final fracRaw = parts[1].padRight(2, '0').substring(0, 2);
final fracPart = int.tryParse(fracRaw);
if (fracPart == null) return 0;
return intPart * 100 + fracPart;
}
/// Большое поле ввода суммы с символом валюты справа.
class AmountInput extends StatelessWidget {
const AmountInput({
super.key,
required this.controller,
required this.onChanged,
this.currencySymbol = '',
this.autofocus = true,
});
final TextEditingController controller;
final ValueChanged<int> onChanged;
final String currencySymbol;
final bool autofocus;
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: controller,
autofocus: autofocus,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
onChanged: (raw) => onChanged(parseAmountToMinor(raw)),
style: monoStyle(
color: p.ink,
fontSize: 40,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: l10n.txAmountHint,
hintStyle: monoStyle(
color: p.ink2,
fontSize: 40,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
currencySymbol,
style: TextStyle(
fontSize: 24,
color: p.ink2,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
}
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../home/presentation/widgets/money_text.dart';
/// Превью «Остаток после: X ₽» для одиночного счёта.
///
/// Знак `delta` определяет, прибавляется или отнимается сумма от текущего
/// баланса (`delta > 0` — добавляется, `delta < 0` — снимается).
class BalanceAfterPreview extends ConsumerWidget {
const BalanceAfterPreview({
super.key,
required this.accountId,
required this.delta,
});
final String accountId;
final int delta;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final current = ref.watch(accountBalanceProvider(accountId)).value ?? 0;
final after = current + delta;
return Row(
children: [
Text(
l10n.txBalanceAfter,
style: TextStyle(fontSize: 12, color: p.ink2),
),
const Spacer(),
MoneyText(
after,
color: after < 0 ? p.negative : p.ink,
fontSize: 14,
fontWeight: FontWeight.w600,
),
],
);
}
}
/// Превью «После перевода: X → Y» для пары счетов.
class TransferAfterPreview extends ConsumerWidget {
const TransferAfterPreview({
super.key,
required this.fromAccountId,
required this.toAccountId,
required this.amountMinor,
});
final String fromAccountId;
final String toAccountId;
final int amountMinor;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final fromBal = ref.watch(accountBalanceProvider(fromAccountId)).value ?? 0;
final toBal = ref.watch(accountBalanceProvider(toAccountId)).value ?? 0;
final fromAfter = fromBal - amountMinor;
final toAfter = toBal + amountMinor;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${l10n.txTransferAfter}:',
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(height: 6),
Row(
children: [
MoneyText(
fromAfter,
color: fromAfter < 0 ? p.negative : p.ink,
fontSize: 18,
fontWeight: FontWeight.w600,
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: p.ink2),
const SizedBox(width: 8),
MoneyText(
toAfter,
color: p.ink,
fontSize: 18,
fontWeight: FontWeight.w600,
),
],
),
],
);
}
}
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
/// Открывает модальный лист выбора категории, отфильтрованный по типу
/// (income/expense). Возвращает `id` выбранной категории или `null`.
Future<String?> showCategoryPicker(
BuildContext context, {
required String userId,
required CategoryType type,
String? currentCategoryId,
}) {
return showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _CategoryPickerSheet(
userId: userId,
type: type,
currentCategoryId: currentCategoryId,
),
);
}
class _CategoryPickerSheet extends ConsumerWidget {
const _CategoryPickerSheet({
required this.userId,
required this.type,
this.currentCategoryId,
});
final String userId;
final CategoryType type;
final String? currentCategoryId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final categoriesAsync =
ref.watch(categoriesByTypeStreamProvider(userId, type));
final categories = categoriesAsync.value ?? const <Category>[];
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, controller) => Container(
decoration: BoxDecoration(
color: p.paper,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: p.line2,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
l10n.pickerCategoryTitle,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
const Spacer(),
Text(
l10n.pickerCategorySubtitle(categories.length),
style: TextStyle(fontSize: 12, color: p.ink2),
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: GridView.builder(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: categories.length + 1,
itemBuilder: (context, i) {
if (i == categories.length) {
return _CreateCategoryTile(
label: l10n.pickerCreateCategory,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.comingSoonShort)),
);
},
);
}
final c = categories[i];
return _CategoryTile(
category: c,
selected: c.id == currentCategoryId,
onTap: () => Navigator.of(context).pop(c.id),
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _CategoryTile extends StatelessWidget {
const _CategoryTile({
required this.category,
required this.selected,
required this.onTap,
});
final Category category;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
final color = colorForCategory(category);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.25 : 0.15),
borderRadius: BorderRadius.circular(14),
border: selected ? Border.all(color: color, width: 2) : null,
),
child: Icon(iconForCategory(category), color: color, size: 26),
),
const SizedBox(height: 6),
Text(
category.name,
style: TextStyle(
fontSize: 12,
color: p.ink,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _CreateCategoryTile extends StatelessWidget {
const _CreateCategoryTile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line),
),
child: Icon(Icons.add, color: p.accent, size: 26),
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
color: p.ink2,
fontWeight: FontWeight.w500,
),
maxLines: 2,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
/// Строка-поле «Дата и время» с открытием date/time picker'ов.
class DateTimeField extends StatelessWidget {
const DateTimeField({
super.key,
required this.value,
required this.onChanged,
});
final DateTime value;
final ValueChanged<DateTime> onChanged;
String _label(BuildContext context) {
final l10n = context.l10n;
final locale = Localizations.localeOf(context).toString();
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dt = DateTime(value.year, value.month, value.day);
final diff = today.difference(dt).inDays;
final hm = DateFormat('HH:mm').format(value);
if (diff == 0) return l10n.todayWithDate(hm);
if (diff == 1) return l10n.yesterdayWithDate(hm);
return '${DateFormat('d MMM', locale).format(value)}, $hm';
}
Future<void> _pick(BuildContext context) async {
final date = await showDatePicker(
context: context,
initialDate: value,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date == null || !context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(value),
);
if (time == null) return;
onChanged(DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
));
}
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
onTap: () => _pick(context),
borderRadius: BorderRadius.circular(12),
child: Row(
children: [
Icon(Icons.event_outlined, size: 18, color: p.ink2),
const SizedBox(width: 8),
Text(
_label(context),
style: TextStyle(
fontSize: 14,
color: p.ink,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../accounts/presentation/widgets/account_icon.dart';
import '../../../home/presentation/widgets/money_text.dart';
/// Строка «откуда / куда» с иконкой, названием счёта и его текущим балансом.
class TransferAccountRow extends ConsumerWidget {
const TransferAccountRow({
super.key,
required this.label,
required this.account,
required this.onTap,
this.hint,
});
final String label;
final Account? account;
final VoidCallback onTap;
final String? hint;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: p.ink2),
),
const SizedBox(height: 6),
if (account == null)
Text(
hint ?? '',
style: TextStyle(
fontSize: 16,
color: p.ink2,
fontWeight: FontWeight.w500,
),
)
else
_AccountBody(account: account!),
],
),
),
);
}
}
class _AccountBody extends ConsumerWidget {
const _AccountBody({required this.account});
final Account account;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final balance = ref.watch(accountBalanceProvider(account.id)).value ?? 0;
final color = Color(account.colorValue ?? 0xFFB8B5AC);
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconForAccount(account), size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Text(
account.name,
style: TextStyle(
fontSize: 16,
color: p.ink,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
MoneyText(
balance,
color: p.ink2,
fontSize: 13,
),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
);
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
/// Сегментированный переключатель Расход / Доход / Перевод.
class TypeSegmented extends StatelessWidget {
const TypeSegmented({
super.key,
required this.value,
required this.onChanged,
});
final TransactionType value;
final ValueChanged<TransactionType> onChanged;
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final items = <_Item>[
_Item(TransactionType.expense, l10n.txTypeExpense),
_Item(TransactionType.income, l10n.txTypeIncome),
_Item(TransactionType.transfer, l10n.txTypeTransfer),
];
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
for (final item in items)
Expanded(
child: _Segment(
label: item.label,
selected: item.type == value,
onTap: () => onChanged(item.type),
),
),
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Material(
color: selected ? p.paper : Colors.transparent,
borderRadius: BorderRadius.circular(10),
elevation: selected ? 1 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: SizedBox(
height: 36,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? p.ink : p.ink2,
),
),
),
),
),
);
}
}
class _Item {
const _Item(this.type, this.label);
final TransactionType type;
final String label;
}
@@ -1,6 +1,5 @@
import 'dart:developer' as developer;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/logging/app_logger.dart';
import '../domain/entities/user.dart';
import 'user_providers.dart';
import 'user_seeder.dart';
@@ -31,11 +30,11 @@ class UsersController extends _$UsersController {
state = const AsyncData(null);
return user;
} catch (e, st) {
developer.log(
AppLogger.error(
'createUser failed (name="$name")',
name: 'UsersController',
error: e,
stackTrace: st,
tag: 'UsersController',
);
state = AsyncError(e, st);
rethrow;
@@ -48,11 +47,11 @@ class UsersController extends _$UsersController {
await ref.read(userRepositoryProvider).rename(id, newName);
state = const AsyncData(null);
} catch (e, st) {
developer.log(
AppLogger.error(
'renameUser failed (id=$id, newName="$newName")',
name: 'UsersController',
error: e,
stackTrace: st,
tag: 'UsersController',
);
state = AsyncError(e, st);
rethrow;
@@ -65,11 +64,11 @@ class UsersController extends _$UsersController {
await ref.read(userRepositoryProvider).delete(id);
state = const AsyncData(null);
} catch (e, st) {
developer.log(
AppLogger.error(
'deleteUser failed (id=$id)',
name: 'UsersController',
error: e,
stackTrace: st,
tag: 'UsersController',
);
state = AsyncError(e, st);
rethrow;
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/logging/app_logger.dart';
import '../../application/active_user_controller.dart';
import '../../application/users_controller.dart';
@@ -34,7 +35,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
.read(activeUserControllerProvider.notifier)
.setActiveUser(user);
// Redirect в роутере подхватит изменение activeUser и переведёт на /home.
} catch (e) {
} catch (e, st) {
AppLogger.error(
'Failed to create user / set active user',
error: e,
stackTrace: st,
tag: 'onboarding',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e')),
@@ -0,0 +1,319 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
import 'package:new_budget/src/features/transactions/application/transactions_controller.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
// ─── Fake ────────────────────────────────────────────────────────────────────
class _CreateCall {
_CreateCall({
required this.userId,
required this.accountId,
this.categoryId,
required this.type,
required this.amount,
required this.date,
this.note,
this.transferToAccountId,
});
final String userId;
final String accountId;
final String? categoryId;
final TransactionType type;
final int amount;
final DateTime date;
final String? note;
final String? transferToAccountId;
}
class FakeTransactionRepository implements TransactionRepository {
final List<_CreateCall> createCalls = [];
final List<String> deleteCalls = [];
Object? nextCreateError;
Object? nextDeleteError;
int _counter = 0;
@override
Future<Transaction> create({
required String userId,
required String accountId,
String? categoryId,
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? transferToAccountId,
}) async {
createCalls.add(_CreateCall(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
note: note,
transferToAccountId: transferToAccountId,
));
if (nextCreateError != null) {
final err = nextCreateError!;
nextCreateError = null;
throw err;
}
return Transaction(
id: 'tx-${++_counter}',
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
note: note,
transferToAccountId: transferToAccountId,
createdAt: DateTime(2024, 1, 1),
);
}
@override
Future<void> delete(String id) async {
deleteCalls.add(id);
if (nextDeleteError != null) {
final err = nextDeleteError!;
nextDeleteError = null;
throw err;
}
}
@override
Stream<List<Transaction>> watchTransactions({
required String userId,
String? accountId,
String? categoryId,
TransactionType? type,
DateTime? from,
DateTime? to,
}) =>
Stream.value([]);
@override
Future<Transaction?> findById(String id) async => null;
@override
Future<Transaction> update(Transaction transaction) async => transaction;
}
// ─── Helper ──────────────────────────────────────────────────────────────────
ProviderContainer _makeContainer(FakeTransactionRepository repo) =>
ProviderContainer(
overrides: [transactionRepositoryProvider.overrideWithValue(repo)],
);
// ─── Tests ───────────────────────────────────────────────────────────────────
void main() {
late FakeTransactionRepository repo;
late ProviderContainer container;
final testDate = DateTime(2024, 6, 15);
setUp(() {
repo = FakeTransactionRepository();
container = _makeContainer(repo);
});
tearDown(() => container.dispose());
group('TransactionsController.createTransaction', () {
test('начальное состояние — AsyncData(null)', () {
expect(
container.read(transactionsControllerProvider),
isA<AsyncData<void>>(),
);
});
test('возвращает созданную транзакцию', () async {
final tx = await container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
categoryId: 'cat-1',
type: TransactionType.expense,
amount: 5000,
date: testDate,
);
expect(tx.userId, 'u-1');
expect(tx.accountId, 'a-1');
expect(tx.type, TransactionType.expense);
expect(tx.amount, 5000);
});
test('передаёт все параметры в репозиторий', () async {
await container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
categoryId: 'cat-1',
type: TransactionType.income,
amount: 9000,
date: testDate,
note: 'Зарплата',
);
expect(repo.createCalls, hasLength(1));
final call = repo.createCalls.first;
expect(call.userId, 'u-1');
expect(call.accountId, 'a-1');
expect(call.categoryId, 'cat-1');
expect(call.type, TransactionType.income);
expect(call.amount, 9000);
expect(call.date, testDate);
expect(call.note, 'Зарплата');
});
test('перевод: передаёт transferToAccountId', () async {
await container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.transfer,
amount: 2000,
date: testDate,
transferToAccountId: 'a-2',
);
expect(repo.createCalls.first.transferToAccountId, 'a-2');
expect(repo.createCalls.first.categoryId, isNull);
});
test('после успеха состояние — AsyncData(null)', () async {
await container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.expense,
amount: 100,
date: testDate,
);
expect(
container.read(transactionsControllerProvider),
isA<AsyncData<void>>(),
);
});
test('ошибка репозитория: пробрасывается вызывающему', () async {
final boom = Exception('db is on fire');
repo.nextCreateError = boom;
await expectLater(
container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.expense,
amount: 100,
date: testDate,
),
throwsA(same(boom)),
);
});
test('ошибка репозитория: записывается в state', () async {
final boom = Exception('db is on fire');
repo.nextCreateError = boom;
await expectLater(
container
.read(transactionsControllerProvider.notifier)
.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.expense,
amount: 100,
date: testDate,
),
throwsA(anything),
);
final state = container.read(transactionsControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
});
// Регрессия: notifier должен оставаться живым между await-границами.
test('после ошибки повторный вызов успешен', () async {
final notifier = container.read(transactionsControllerProvider.notifier);
repo.nextCreateError = Exception('первая попытка');
await expectLater(
notifier.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.expense,
amount: 100,
date: testDate,
),
throwsA(isA<Exception>()),
);
final tx = await notifier.createTransaction(
userId: 'u-1',
accountId: 'a-1',
type: TransactionType.expense,
amount: 200,
date: testDate,
);
expect(tx.amount, 200);
expect(
container.read(transactionsControllerProvider),
isA<AsyncData<void>>(),
);
});
});
group('TransactionsController.deleteTransaction', () {
test('вызывает repo.delete с правильным id', () async {
await container
.read(transactionsControllerProvider.notifier)
.deleteTransaction('tx-99');
expect(repo.deleteCalls, ['tx-99']);
});
test('после удаления состояние — AsyncData(null)', () async {
await container
.read(transactionsControllerProvider.notifier)
.deleteTransaction('tx-1');
expect(
container.read(transactionsControllerProvider),
isA<AsyncData<void>>(),
);
});
test('ошибка при удалении: пробрасывается и пишется в state', () async {
final boom = Exception('delete fail');
repo.nextDeleteError = boom;
await expectLater(
container
.read(transactionsControllerProvider.notifier)
.deleteTransaction('tx-x'),
throwsA(same(boom)),
);
final state = container.read(transactionsControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
});
});
}
@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/categories/application/categories_controller.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
import 'package:new_budget/src/features/transactions/application/transactions_controller.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart';
import 'package:new_budget/src/features/transactions/presentation/state/transaction_draft.dart';
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
// ─── Fakes ───────────────────────────────────────────────────────────────────
class FakeActiveUserController extends ActiveUserController {
@override
Future<User?> build() async =>
User(id: 'test-uid', name: 'Test', createdAt: DateTime(2024));
}
class _TxCall {
_TxCall({
required this.userId,
required this.accountId,
this.categoryId,
required this.type,
required this.amount,
required this.date,
this.note,
this.transferToAccountId,
});
final String userId;
final String accountId;
final String? categoryId;
final TransactionType type;
final int amount;
final DateTime date;
final String? note;
final String? transferToAccountId;
}
class FakeTransactionsController extends TransactionsController {
final List<_TxCall> calls = [];
@override
AsyncValue<void> build() => const AsyncData(null);
@override
Future<Transaction> createTransaction({
required String userId,
required String accountId,
String? categoryId,
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? transferToAccountId,
}) async {
calls.add(_TxCall(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
note: note,
transferToAccountId: transferToAccountId,
));
return Transaction(
id: 'tx-1',
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
note: note,
transferToAccountId: transferToAccountId,
createdAt: DateTime(2024),
);
}
}
// Заглушка репозитория — не должна вызываться при создании (txId == null).
class _UnusedTransactionRepo implements TransactionRepository {
@override
dynamic noSuchMethod(Invocation i) =>
throw StateError('TransactionRepository should not be called in form create tests');
}
// ─── Helper ──────────────────────────────────────────────────────────────────
Widget _buildForm(FakeTransactionsController fakeCtrl) {
return ProviderScope(
overrides: [
activeUserControllerProvider.overrideWith(() => FakeActiveUserController()),
transactionsControllerProvider.overrideWith(() => fakeCtrl),
transactionRepositoryProvider.overrideWithValue(_UnusedTransactionRepo()),
accountsStreamProvider('test-uid').overrideWith(
(ref) => Stream<List<Account>>.value(const []),
),
categoriesByTypeStreamProvider('test-uid', CategoryType.expense).overrideWith(
(ref) => Stream<List<Category>>.value(const []),
),
categoriesByTypeStreamProvider('test-uid', CategoryType.income).overrideWith(
(ref) => Stream<List<Category>>.value(const []),
),
],
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const TransactionFormScreen(),
),
);
}
/// Получить container из дерева после того, как форма отрисовалась.
ProviderContainer _container(WidgetTester tester) =>
ProviderScope.containerOf(tester.element(find.byType(TransactionFormScreen)));
/// Найти текст в снекбаре (не путать с плейсхолдерами в форме).
Finder _snackText(String text) =>
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
// ─── Tests ───────────────────────────────────────────────────────────────────
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
late FakeTransactionsController fakeCtrl;
setUp(() => fakeCtrl = FakeTransactionsController());
// ── Валидация суммы ────────────────────────────────────────────────────────
testWidgets('Save с amount=0 показывает встроенную ошибку "Enter an amount"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump(); // activeUserControllerProvider разрешается
// Нажимаем Save не вводя сумму (draft.amountMinor == 0).
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(find.text('Enter an amount'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация счёта ────────────────────────────────────────────────────────
testWidgets('Save без счёта показывает снекбар "Pick an account"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
_container(tester)
.read(transactionDraftControllerProvider(null).notifier)
.setAmount(500);
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(_snackText('Pick an account'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация категории (расход) ───────────────────────────────────────────
testWidgets('Расход: Save без категории показывает снекбар "Pick a category"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setAmount(500);
notifier.setAccount('a-1');
// categoryId остаётся null
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(_snackText('Pick a category'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация перевода: нет получателя ────────────────────────────────────
testWidgets(
'Перевод: Save без получателя показывает снекбар "Pick a destination account"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(500);
notifier.setAccount('a-1');
// transferToAccountId остаётся null
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(_snackText('Pick a destination account'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Валидация перевода: одинаковый счёт ───────────────────────────────────
testWidgets(
'Перевод: одинаковый источник и получатель → снекбар "Source and destination must differ"',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(500);
notifier.setAccount('a-1');
notifier.setTransferToAccount('a-1');
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(_snackText('Source and destination must differ'), findsOneWidget);
expect(fakeCtrl.calls, isEmpty);
});
// ── Happy path: расход ────────────────────────────────────────────────────
testWidgets('Валидный расход: createTransaction вызывается с правильными параметрами',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final testDate = DateTime(2024, 6, 15, 12);
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.expense);
notifier.setAmount(5000);
notifier.setAccount('a-1');
notifier.setCategory('cat-1');
notifier.setDate(testDate);
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(fakeCtrl.calls, hasLength(1));
final call = fakeCtrl.calls.first;
expect(call.userId, 'test-uid');
expect(call.accountId, 'a-1');
expect(call.categoryId, 'cat-1');
expect(call.type, TransactionType.expense);
expect(call.amount, 5000);
expect(call.date, testDate);
});
// ── Happy path: перевод ───────────────────────────────────────────────────
testWidgets(
'Валидный перевод: createTransaction вызывается с transferToAccountId, categoryId=null',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setType(TransactionType.transfer);
notifier.setAmount(3000);
notifier.setAccount('a-1');
notifier.setTransferToAccount('a-2');
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(fakeCtrl.calls, hasLength(1));
final call = fakeCtrl.calls.first;
expect(call.type, TransactionType.transfer);
expect(call.transferToAccountId, 'a-2');
expect(call.categoryId, isNull);
});
// ── Note trimming ─────────────────────────────────────────────────────────
testWidgets('Пустая заметка передаётся как null (не как пустая строка)',
(tester) async {
await tester.pumpWidget(_buildForm(fakeCtrl));
await tester.pump();
final notifier =
_container(tester).read(transactionDraftControllerProvider(null).notifier);
notifier.setAmount(1000);
notifier.setAccount('a-1');
notifier.setCategory('cat-1');
notifier.setNote(' '); // пробелы → null после trim
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(fakeCtrl.calls, hasLength(1));
expect(fakeCtrl.calls.first.note, isNull);
});
}
@@ -0,0 +1,215 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/transactions/data/repositories/transaction_repository_impl.dart';
void main() {
late AppDatabase db;
late TransactionRepositoryImpl repo;
const userId = 'user-1';
const accountId = 'account-1';
const account2Id = 'account-2';
const categoryId = 'category-1';
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
repo = TransactionRepositoryImpl(db.transactionsDao);
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: userId, name: 'Тест'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: accountId,
userId: userId,
name: 'Основной',
),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: account2Id,
userId: userId,
name: 'Сберегательный',
),
);
await db.categoriesDao.insertCategory(
CategoriesTableCompanion.insert(
id: categoryId,
userId: userId,
name: 'Еда',
),
);
});
tearDown(() => db.close());
// ─── create: расход ────────────────────────────────────────────────────────
group('TransactionRepository.create — расход', () {
test('возвращает сущность с правильными полями', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: TransactionType.expense,
amount: 5000,
date: DateTime(2024, 1, 15),
);
expect(tx.userId, userId);
expect(tx.accountId, accountId);
expect(tx.categoryId, categoryId);
expect(tx.type, TransactionType.expense);
expect(tx.amount, 5000);
expect(tx.date, DateTime(2024, 1, 15));
expect(tx.note, isNull);
expect(tx.transferToAccountId, isNull);
});
test('генерирует непустой UUID (36 символов)', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 100,
date: DateTime(2024, 1, 1),
);
expect(tx.id, isNotEmpty);
expect(tx.id.length, 36);
});
test('два create дают разные id', () async {
final tx1 = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 100,
date: DateTime(2024, 1, 1),
);
final tx2 = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 200,
date: DateTime(2024, 1, 2),
);
expect(tx1.id, isNot(tx2.id));
});
test('сохраняет опциональную заметку', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 1000,
date: DateTime(2024, 1, 1),
note: 'Ужин в ресторане',
);
expect(tx.note, 'Ужин в ресторане');
});
test('null note сохраняется как null', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 500,
date: DateTime(2024, 1, 1),
);
expect(tx.note, isNull);
});
test('сохранённую транзакцию можно получить через findById', () async {
final created = await repo.create(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: TransactionType.expense,
amount: 3500,
date: DateTime(2024, 1, 10),
);
final found = await repo.findById(created.id);
expect(found, isNotNull);
expect(found!.id, created.id);
expect(found.amount, 3500);
});
});
// ─── create: доход ─────────────────────────────────────────────────────────
group('TransactionRepository.create — доход', () {
test('создаёт транзакцию типа income', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.income,
amount: 10000,
date: DateTime(2024, 1, 20),
);
expect(tx.type, TransactionType.income);
expect(tx.amount, 10000);
expect(tx.transferToAccountId, isNull);
});
});
// ─── create: перевод ───────────────────────────────────────────────────────
group('TransactionRepository.create — перевод', () {
test('создаёт перевод: transferToAccountId установлен, categoryId = null',
() async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.transfer,
amount: 3000,
date: DateTime(2024, 2, 1),
transferToAccountId: account2Id,
);
expect(tx.type, TransactionType.transfer);
expect(tx.transferToAccountId, account2Id);
expect(tx.categoryId, isNull);
});
});
// ─── findById ──────────────────────────────────────────────────────────────
group('TransactionRepository.findById', () {
test('возвращает null для несуществующего id', () async {
final tx = await repo.findById('not-exist');
expect(tx, isNull);
});
test('возвращает транзакцию по известному id', () async {
final created = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 500,
date: DateTime(2024, 3, 1),
);
final found = await repo.findById(created.id);
expect(found, isNotNull);
expect(found!.id, created.id);
});
});
// ─── delete ────────────────────────────────────────────────────────────────
group('TransactionRepository.delete', () {
test('удалённая транзакция не находится через findById', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 800,
date: DateTime(2024, 4, 1),
);
await repo.delete(tx.id);
final found = await repo.findById(tx.id);
expect(found, isNull);
});
});
}