Add add trans
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "Выберите счёт получателя"
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user