Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

503 changes: 503 additions & 0 deletions .planning/sketches/186-transaction-amount-and-direction/README.md

Large diffs are not rendered by default.

783 changes: 783 additions & 0 deletions .planning/sketches/186-transaction-amount-and-direction/index.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .planning/sketches/MANIFEST.md

Large diffs are not rendered by default.

238 changes: 143 additions & 95 deletions lib/chart/crypto_simple_chart.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:genius_wallet/components/cards/gw_row_rhythm.dart';
import 'package:genius_wallet/components/effects/gw_hover_row.dart';
import 'package:genius_wallet/theme/genius_wallet_consts.dart';
import 'package:genius_wallet/theme/genius_wallet_typography.dart';
import 'package:genius_wallet/theme/gw_colors.dart';
import 'package:genius_wallet/utils/image_utils.dart';
Expand All @@ -17,7 +19,6 @@ class CryptoSparkLineChart extends StatelessWidget {
final double currentPrice;
final double priceChangePercent;
final List<double>? sparkline;
final double iconSize;
final void Function()? onTap;

const CryptoSparkLineChart({
Expand All @@ -29,7 +30,6 @@ class CryptoSparkLineChart extends StatelessWidget {
required this.currentPrice,
required this.priceChangePercent,
this.sparkline,
this.iconSize = 34,
this.iconPath,
this.onTap,
});
Expand Down Expand Up @@ -71,109 +71,157 @@ class CryptoSparkLineChart extends StatelessWidget {
// `GWHoverRow` brings its own transparent `Material` above that paint and
// clips to `radiusMd`. The tile keeps its layout and gives up only its tap:
// two ink responses stacked on one row would double the highlight.
// ROW RHYTHM, 2026-08-07 (260807-wbu). `ListTile` snapped this row to
// Material's default two-line 72px tile with NO `contentPadding` declared
// anywhere, so 100% of its 13.25 / 16.75 slack and its 16px wall were the
// tile's own centring default, not a choice made in this repo - see
// `gw_row_rhythm.dart`. `Padding(kGWRowPadding)` + `Row` replaces the
// tile with the same shape `transaction_displays.dart:390-627` ships, so
// the four numbers Jakub named come from one file instead of a Material
// default nobody wrote down.
return GWHoverRow(
onTap: onTap,
semanticLabel: title,
child: ListTile(
leading: buildTokenIcon(iconPath: iconPath, size: iconSize),
// NAME is primary (titleMd / textPrimary), price secondary (bodySm /
// textSecondary) — mirrors the Assets CoinCardRow hierarchy.
// A FIXED style with an ellipsis, never AutoSizeText. AutoSizeText
// searches for a font size that fits the box, so as the window is
// drag-resized it emits a different size — and therefore a different
// TextStyle, and therefore a different skia ParagraphCacheKey — on
// essentially every frame. With one of these per market row, that fills
// and evicts the fixed-size cache continuously, layout never settles,
// no frame is ever committed, and the macOS embedder blocks forever in
// ResizeSynchronizer.beginResize. That is the freeze commit 37639d5
// diagnosed; 37639d5 only quantised the OTHER site's height-derived
// font size and left this width-driven search in place.
title: Text(
title,
style: GeniusWalletTypography.titleMd.copyWith(color: gw.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Ticker · price when a symbol is given (Jakub 2026-07-24 — "add the
// ticker if there's room"); ellipsis so a long pair degrades gracefully
// in the narrow dashboard panel rather than overflowing.
subtitle: Text(
symbol != null && symbol!.isNotEmpty
? '${symbol!.toUpperCase()} · $formattedPrice'
: formattedPrice,
style: GeniusWalletTypography.bodySm.copyWith(
color: gw.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
titleAlignment: ListTileTitleAlignment.center,
// Order: % chip, then the sparkline as the LAST (rightmost) column
// (Jakub 2026-07-24). textDirection.rtl flips the child order without
// moving the big LineChart block: the first child (sparkline) lays out on
// the right, the last (% chip) on the left. Each child's own text keeps
// the app's LTR Directionality, so "+2.4%" renders normally.
trailing: Row(
textDirection: TextDirection.rtl,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
child: Padding(
padding: kGWRowPadding,
child: Row(
children: [
// 72x32, barWidth 1.6 — Jakub, 2026-07-25, matched to the Markets
// page's own table-column sparkline so the two renderings of the
// same data read as one component instead of the dashboard's
// being a visibly shrunk 56x20 copy. The Markets page dropped its
// table (and that sparkline) for cards in quick 260807-bxs; this
// one is unaffected and keeps the same geometry.
SizedBox(
width: 72,
height: 32,
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: getSparklineChartData(),
isCurved: true,
color: changeColor,
barWidth: 1.6,
dotData: const FlDotData(show: false),
),
],
titlesData: const FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
buildTokenIcon(iconPath: iconPath, size: kGWRowIconSize),
const SizedBox(width: kGWRowIconToText),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// NAME is primary (titleMd / textPrimary), price secondary
// (bodySm / textSecondary) - mirrors the Assets CoinCardRow
// hierarchy.
// A FIXED style with an ellipsis, never AutoSizeText.
// AutoSizeText searches for a font size that fits the box,
// so as the window is drag-resized it emits a different
// size - and therefore a different TextStyle, and therefore
// a different skia ParagraphCacheKey - on essentially every
// frame. With one of these per market row, that fills and
// evicts the fixed-size cache continuously, layout never
// settles, no frame is ever committed, and the macOS
// embedder blocks forever in
// ResizeSynchronizer.beginResize. That is the freeze commit
// 37639d5 diagnosed; 37639d5 only quantised the OTHER
// site's height-derived font size and left this
// width-driven search in place.
Text(
title,
style: GeniusWalletTypography.titleMd.copyWith(
color: gw.textPrimary,
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: GeniusWalletConsts.space2),
// Ticker · price when a symbol is given (Jakub 2026-07-24 -
// "add the ticker if there's room"); ellipsis so a long
// pair degrades gracefully in the narrow dashboard panel
// rather than overflowing.
Text(
symbol != null && symbol!.isNotEmpty
? '${symbol!.toUpperCase()} · $formattedPrice'
: formattedPrice,
style: GeniusWalletTypography.bodySm.copyWith(
color: gw.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
),
],
),
),
// space6 = 12
const SizedBox(width: 12),
// Filled % chip (status tint + status fg), like Assets.
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: changeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
"${priceChangePercent >= 0 ? "+" : ""}${priceChangePercent.toStringAsFixed(2)}%",
style: GeniusWalletTypography.labelMd.copyWith(
color: changeColor,
fontWeight: FontWeight.w600,
const SizedBox(width: kGWRowIconToText),
// NON-FLEX, unlike `coin_card_row.dart`'s `Flexible` trailing -
// deliberate, and the largest obstacle to a future shared
// component (see `gw_row_rhythm.dart`'s upgrade-path note), so
// written down here rather than left to look like an oversight.
// This trailing's width is bounded BY CONSTRUCTION: a fixed
// 72x32 `LineChart`, a fixed `space6` gap, and a chip whose
// longest label is a signed three-digit percentage - there is no
// unbounded text in it, so it cannot raise a `RenderFlex`
// overflow the way an unbounded `Text` could. Giving it flex
// would only shrink the sparkline for no benefit, which is why
// Assets' trailing (two unbounded `Text`s that must ellipsise)
// and this one do not share a flex model.
//
// Order: % chip, then the sparkline as the LAST (rightmost)
// column (Jakub 2026-07-24). textDirection.rtl flips the child
// order without moving the big LineChart block: the first child
// (sparkline) lays out on the right, the last (% chip) on the
// left. Each child's own text keeps the app's LTR
// Directionality, so "+2.4%" renders normally.
Row(
textDirection: TextDirection.rtl,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 72x32, barWidth 1.6 - Jakub, 2026-07-25, matched to the
// Markets page's own table-column sparkline so the two
// renderings of the same data read as one component instead of
// the dashboard's being a visibly shrunk 56x20 copy. The
// Markets page dropped its table (and that sparkline) for cards
// in quick 260807-bxs; this one is unaffected and keeps the
// same geometry.
SizedBox(
width: 72,
height: 32,
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: getSparklineChartData(),
isCurved: true,
color: changeColor,
barWidth: 1.6,
dotData: const FlDotData(show: false),
),
],
titlesData: const FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
),
),
),
),
// space6 = 12
const SizedBox(width: 12),
// Filled % chip (status tint + status fg), like Assets.
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: changeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
"${priceChangePercent >= 0 ? "+" : ""}${priceChangePercent.toStringAsFixed(2)}%",
style: GeniusWalletTypography.labelMd.copyWith(
color: changeColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
Expand Down
78 changes: 78 additions & 0 deletions lib/components/cards/gw_row_rhythm.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/// The four numbers Jakub named on 2026-08-07, walking the Transactions pilot
/// on his iPhone ("Sidney") and asking for it everywhere: from the card
/// content edge, the leading glyph is 38, the gap to the text column is 8,
/// the text column runs to 8 from the right content edge, and every rule
/// between two rows in a list sits 12 above / 1 / 12 below.
///
/// This file holds those numbers and nothing else - no widget, no
/// abstraction. `test/components/gw_row_rhythm_test.dart` MEASURES painted
/// ink against them, so a row cannot drift back to an undeclared default the
/// way Assets and Markets did (see (3) below).
///
/// Full derivation:
/// `.planning/quick/260807-v6m-dashboard-panel-bottom-inset-mirrors-the/SEPARATOR-RHYTHM-MEASURED.md`
///
/// **(1) Every spacing value here is an existing token.** [kGWRowWall],
/// [kGWRowIconToText] and [kGWRowSeparatorGap] are all named
/// [GeniusWalletConsts] entries (`space4` and `space6`) - nothing off-grid is
/// introduced by this file.
///
/// **(2) [kGWRowIconSize] (38) is the one untokened value, and it is a SIZE,
/// not a spacing.** It is Jakub's pick, taken from what the Assets row
/// (`CoinCardRow`) already shipped -
/// `buildTokenIcon(iconPath: iconPath, size: 38)` - not a new number invented
/// for this task.
///
/// **(3) These four numbers previously came from Material's default
/// two-line `ListTile` height and were undeclared anywhere in this repo.**
/// The measurement report above found Assets rendering 15.63 / 20.00 around
/// its rule and Markets rendering 13.25 / 16.75, neither of which any line in
/// this codebase chose - both were `ListTile`'s centring slack, invisible in
/// source at both the row and the divider that draws the rule. This file is
/// what stops that: one place the numbers live, and one test that measures
/// what actually renders rather than trusting source padding to predict it.
library;

import 'package:flutter/widgets.dart';
import 'package:genius_wallet/theme/genius_wallet_consts.dart';

/// The gap from the card's inner content edge to the row's first painted
/// pixel, on every side: the leading glyph's left, and the trailing content's
/// right. `space4` (8) - see (1).
const double kGWRowWall = GeniusWalletConsts.space4;

/// The gap between the leading glyph and the text column. `space4` (8), the
/// same value as [kGWRowWall] so the row reads as one rhythm - a wall gap and
/// an internal gap that happen to differ would look like two different
/// decisions rather than one.
const double kGWRowIconToText = GeniusWalletConsts.space4;

/// The gap above a rule between two rows, and the gap below it - `space6`
/// (12). The rule's own height (1) is drawn at each call site, not held here.
///
/// `space6` is the midpoint of the app's measured [4, 20] range across the
/// three lists, sits on the 4-pt grid, and was already the shipped symmetric
/// value at `route_details_card.dart:104` and `markets_table.dart:181`/`:228`
/// before this task touched anything - a value this app had already settled
/// on, not a new one.
const double kGWRowSeparatorGap = GeniusWalletConsts.space6;

/// The leading glyph's side length. Untokened by design - see (2) above.
const double kGWRowIconSize = 38;

/// The row's own padding: [kGWRowWall] on every side horizontally,
/// [kGWRowSeparatorGap] top and bottom. Every row importing this constant
/// gets the same box; nothing about a specific row's anatomy leaks in here.
const EdgeInsets kGWRowPadding = EdgeInsets.symmetric(
horizontal: kGWRowWall,
vertical: kGWRowSeparatorGap,
);

/// The text column's left edge, measured from the row's own left edge.
///
/// DERIVED, never typed as a literal (it is not "54" anywhere in this file):
/// [kGWRowWall] + [kGWRowIconSize] + [kGWRowIconToText]. A future change to
/// any one term moves this - and the test that pins it - with it, instead of
/// silently going stale the way `dashboard_markets.dart`'s `contentTopInset`
/// and `transaction_displays.dart`'s class doc both did this same week.
const double kGWRowTextColumnX = kGWRowWall + kGWRowIconSize + kGWRowIconToText;
15 changes: 6 additions & 9 deletions lib/components/cards/gw_token_row.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:genius_wallet/components/cards/gw_row_rhythm.dart';
import 'package:genius_wallet/components/effects/gw_hover_row.dart';
import 'package:genius_wallet/theme/genius_wallet_consts.dart';
import 'package:genius_wallet/theme/genius_wallet_typography.dart';
import 'package:genius_wallet/theme/gw_colors.dart';

Expand Down Expand Up @@ -38,15 +38,12 @@ class GWTokenRow extends StatelessWidget {
return GWHoverRow(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: GeniusWalletConsts.space6,
vertical: GeniusWalletConsts.space4,
),
padding: kGWRowPadding,
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
width: kGWRowIconSize,
height: kGWRowIconSize,
child:
iconWidget ??
(iconAsset != null
Expand All @@ -57,7 +54,7 @@ class GWTokenRow extends StatelessWidget {
)
: const _FallbackDot()),
),
const SizedBox(width: GeniusWalletConsts.space6),
const SizedBox(width: kGWRowIconToText),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down Expand Up @@ -124,7 +121,7 @@ class _FallbackDot extends StatelessWidget {
// fail-soft read.
final gw = Theme.of(context).extension<GWColors>() ?? GWColors.dark();
return CircleAvatar(
radius: 20,
radius: kGWRowIconSize / 2,
backgroundColor: gw.surfaceMenu,
child: Icon(Icons.token, size: 18, color: gw.textSecondary),
);
Expand Down
Loading
Loading