From 56c07f581723feca62e9fd0a3aab0c667e0acb62 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 15:22:16 -0600 Subject: [PATCH 1/4] Work the mobile punch list, and let a client name a budget type The MCP budget type enum matched atoms while the value arrives from JSON as a string, so every create or update naming a type was refused as Invalid params. An end-to-end server test now goes through the transport that validates it. Marking a transfer marks both sides reviewed, since saying what a pair is leaves nothing else to decide, and a new mark_transfer tool plus transfer_id on the transaction listing gives an agent a way to do it. Transfers were already out of every spent total; the missing coverage on spent-by-month is added. On the phone: no swipe actions, the bulk bar cut to Review / Spend from / More, grouped budgets, no edit on Spendable or credit cards, BALANCE on card debt, Apple's mark on Wallet accounts, and sign out lands on the sign-in screen. Co-Authored-By: Claude Opus 5 --- .../actions/calculate_spent_by_month_test.exs | 24 ++ .../transactions/actions/mark_as_transfer.ex | 9 +- .../actions/mark_as_transfer_test.exs | 8 + lib/spendable_web/live/budgets_test.exs | 2 +- lib/spendable_web/mcp/server.ex | 1 + lib/spendable_web/mcp/server_test.exs | 43 ++- lib/spendable_web/mcp/tools/create_budget.ex | 3 +- .../mcp/tools/create_budget_test.exs | 2 +- .../mcp/tools/list_transactions.ex | 5 +- lib/spendable_web/mcp/tools/mark_transfer.ex | 46 +++ .../mcp/tools/mark_transfer_test.exs | 86 +++++ lib/spendable_web/mcp/tools/update_budget.ex | 3 +- .../mcp/tools/update_budget_test.exs | 7 + lib/spendable_web/utils/budget_card.ex | 4 +- mobile/assets/icons/apple-logo.svg | 1 + mobile/lib/app.dart | 18 +- mobile/lib/banks/account_label.dart | 5 + mobile/lib/banks/banks_providers.dart | 3 + mobile/lib/banks/banks_screen.dart | 38 +- mobile/lib/budgets/budget_card.dart | 4 +- mobile/lib/budgets/budgets_providers.dart | 29 +- mobile/lib/budgets/budgets_screen.dart | 18 +- mobile/lib/design/glass_menu.dart | 52 +-- mobile/lib/design/glass_sheet.dart | 3 + mobile/lib/design/glyph_icon.dart | 1 + .../transactions/transactions_controller.dart | 15 - .../lib/transactions/transactions_screen.dart | 331 ++++++++---------- mobile/pubspec.lock | 8 - mobile/pubspec.yaml | 1 - mobile/test/budgets/budgets_screen_test.dart | 64 ++++ mobile/test/design/layout_test.dart | 12 +- .../transactions_screen_test.dart | 11 + shared/budget_cards.json | 4 +- 33 files changed, 595 insertions(+), 266 deletions(-) create mode 100644 lib/spendable_web/mcp/tools/mark_transfer.ex create mode 100644 lib/spendable_web/mcp/tools/mark_transfer_test.exs create mode 100644 mobile/assets/icons/apple-logo.svg create mode 100644 mobile/lib/banks/account_label.dart diff --git a/lib/spendable/budgets/actions/calculate_spent_by_month_test.exs b/lib/spendable/budgets/actions/calculate_spent_by_month_test.exs index acdf0f95..dcb1bd7f 100644 --- a/lib/spendable/budgets/actions/calculate_spent_by_month_test.exs +++ b/lib/spendable/budgets/actions/calculate_spent_by_month_test.exs @@ -34,4 +34,28 @@ defmodule Spendable.Budgets.Actions.CalculateSpentByMonthTest do assert [%{month: ^current_month, spent: spent}] = Budgets.calculate_spent_by_month(scope) assert Decimal.eq?(spent, "-25.00") end + + # A transfer moves money between the user's own accounts, so neither side is spending. + test "leaves a transfer out of the month", %{scope: scope} do + current_month = Date.beginning_of_month(Date.utc_today()) + + {:ok, out} = + Transactions.create_transaction(scope, %{ + "amount" => "-500.00", + "date" => Date.utc_today(), + "name" => "Transfer to savings" + }) + + {:ok, into} = + Transactions.create_transaction(scope, %{ + "amount" => "500.00", + "date" => Date.utc_today(), + "name" => "Transfer from checking" + }) + + {:ok, _pair} = Transactions.mark_as_transfer(scope, out, into) + + assert [%{month: ^current_month, spent: spent}] = Budgets.calculate_spent_by_month(scope) + assert Decimal.eq?(spent, 0) + end end diff --git a/lib/spendable/transactions/actions/mark_as_transfer.ex b/lib/spendable/transactions/actions/mark_as_transfer.ex index 2c241003..8ca133ce 100644 --- a/lib/spendable/transactions/actions/mark_as_transfer.ex +++ b/lib/spendable/transactions/actions/mark_as_transfer.ex @@ -10,7 +10,8 @@ defmodule Spendable.Transactions.Actions.MarkAsTransfer do The pair has to be one transaction going out and one coming in, since a transfer moves money rather than spending it. Clearing their allocations parks the whole of each amount on - Spendable, where the two opposite signs cancel. + Spendable, where the two opposite signs cancel, and both sides are marked reviewed because + saying what a pair is leaves nothing else to decide about it. """ def mark_as_transfer( %Scope{user: %{id: user_id}}, @@ -39,7 +40,11 @@ defmodule Spendable.Transactions.Actions.MarkAsTransfer do defp link(transaction, transfer_id) do transaction |> Repo.preload(:budget_allocations) - |> Transaction.changeset(%{"transfer_id" => transfer_id, "budget_allocations" => []}) + |> Transaction.changeset(%{ + "transfer_id" => transfer_id, + "budget_allocations" => [], + "reviewed" => true + }) |> Repo.update!() end end diff --git a/lib/spendable/transactions/actions/mark_as_transfer_test.exs b/lib/spendable/transactions/actions/mark_as_transfer_test.exs index c9b0178f..e19036a4 100644 --- a/lib/spendable/transactions/actions/mark_as_transfer_test.exs +++ b/lib/spendable/transactions/actions/mark_as_transfer_test.exs @@ -48,6 +48,14 @@ defmodule Spendable.Transactions.Actions.MarkAsTransferTest do Transactions.get_transaction(scope, id: in_id) end + # Saying what a pair is leaves nothing else to decide, so neither side stays in the review queue. + test "marks both sides reviewed", %{scope: scope, out: out, out_id: out_id, in: in_, in_id: in_id} do + assert {:ok, _pair} = Transactions.mark_as_transfer(scope, out, in_) + + assert {:ok, %Transaction{reviewed: true}} = Transactions.get_transaction(scope, id: out_id) + assert {:ok, %Transaction{reviewed: true}} = Transactions.get_transaction(scope, id: in_id) + end + # A transfer moves money rather than spending it, so an envelope it was assigned to gets it back. test "moves the whole amount to Spendable", %{scope: scope, out: out, in: in_} do {:ok, budget} = Budgets.create_budget(scope, %{"name" => "Groceries"}) diff --git a/lib/spendable_web/live/budgets_test.exs b/lib/spendable_web/live/budgets_test.exs index 2b853cf9..31576412 100644 --- a/lib/spendable_web/live/budgets_test.exs +++ b/lib/spendable_web/live/budgets_test.exs @@ -186,7 +186,7 @@ defmodule SpendableWeb.Live.BudgetsTest do {:ok, _view, html} = live(conn, ~p"/budgets") assert html =~ "SPENT" - assert html =~ "No limit set" + refute html =~ "No limit set" end # Dividing by the budgeted amount has to survive a budget set to nothing. diff --git a/lib/spendable_web/mcp/server.ex b/lib/spendable_web/mcp/server.ex index 52b3d6f3..0e3595a2 100644 --- a/lib/spendable_web/mcp/server.ex +++ b/lib/spendable_web/mcp/server.ex @@ -19,6 +19,7 @@ defmodule SpendableWeb.MCP.Server do component(SpendableWeb.MCP.Tools.ListBudgets) component(SpendableWeb.MCP.Tools.ListSplits) component(SpendableWeb.MCP.Tools.ListTransactions) + component(SpendableWeb.MCP.Tools.MarkTransfer) component(SpendableWeb.MCP.Tools.UpdateBudget) component(SpendableWeb.MCP.Tools.UpdateSplit) end diff --git a/lib/spendable_web/mcp/server_test.exs b/lib/spendable_web/mcp/server_test.exs index 3c47f6d8..b35e9ab3 100644 --- a/lib/spendable_web/mcp/server_test.exs +++ b/lib/spendable_web/mcp/server_test.exs @@ -49,12 +49,6 @@ defmodule SpendableWeb.MCP.ServerTest do |> put_req_header("content-type", "application/json") |> put_req_header("accept", "application/json") - %{conn: conn, scope: scope} - end - - test "runs a tool as the user the token was issued to", %{conn: conn, scope: scope} do - {:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"}) - initialized = post(conn, ~p"/mcp", %{ "jsonrpc" => "2.0", @@ -74,6 +68,12 @@ defmodule SpendableWeb.MCP.ServerTest do post(conn, ~p"/mcp", %{"jsonrpc" => "2.0", "method" => "notifications/initialized"}) + %{conn: conn, scope: scope} + end + + test "runs a tool as the user the token was issued to", %{conn: conn, scope: scope} do + {:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"}) + called = post(conn, ~p"/mcp", %{ "jsonrpc" => "2.0", @@ -86,6 +86,37 @@ defmodule SpendableWeb.MCP.ServerTest do json_response(called, 200) end + # The type arrives as a JSON string and is validated before anything casts it, so an enum of + # atoms rejected every call that named one. + test "takes a budget type as the JSON string a client sends", %{conn: conn} do + called = + post(conn, ~p"/mcp", %{ + "jsonrpc" => "2.0", + "id" => 2, + "method" => "tools/call", + "params" => %{ + "name" => "create_budget", + "arguments" => %{"name" => "Home Renovation", "type" => "tracking"} + } + }) + + assert %{"result" => %{"structuredContent" => %{"budget" => %{"type" => "tracking"}}}} = + json_response(called, 200) + end + + test "keeps an ampersand in a name it is given", %{conn: conn} do + called = + post(conn, ~p"/mcp", %{ + "jsonrpc" => "2.0", + "id" => 2, + "method" => "tools/call", + "params" => %{"name" => "create_budget", "arguments" => %{"name" => "Auto Insurance & Fees"}} + }) + + assert %{"result" => %{"structuredContent" => %{"budget" => %{"name" => "Auto Insurance & Fees"}}}} = + json_response(called, 200) + end + test "refuses a call with no bearer token" do assert %{status: 401} = build_conn() diff --git a/lib/spendable_web/mcp/tools/create_budget.ex b/lib/spendable_web/mcp/tools/create_budget.ex index 473f0d15..a92a4beb 100644 --- a/lib/spendable_web/mcp/tools/create_budget.ex +++ b/lib/spendable_web/mcp/tools/create_budget.ex @@ -14,7 +14,8 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do schema do field :name, {:required, :string}, description: "What the budget is called, e.g. \"Groceries\"." - field :type, {:enum, [:envelope, :goal, :tracking]}, + # Strings, not atoms: the value arrives from JSON and is compared before anything casts it. + field :type, {:enum, ["envelope", "goal", "tracking"]}, description: "envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <> "without reserving anything. Defaults to envelope." diff --git a/lib/spendable_web/mcp/tools/create_budget_test.exs b/lib/spendable_web/mcp/tools/create_budget_test.exs index e20b328b..bc51e351 100644 --- a/lib/spendable_web/mcp/tools/create_budget_test.exs +++ b/lib/spendable_web/mcp/tools/create_budget_test.exs @@ -29,7 +29,7 @@ defmodule SpendableWeb.MCP.Tools.CreateBudgetTest do test "records an adjustment when a starting balance is given", %{frame: frame, scope: scope} do assert {:reply, %Response{isError: false}, ^frame} = - CreateBudget.execute(%{name: "Groceries", type: :goal, balance: "40.00"}, frame) + CreateBudget.execute(%{name: "Groceries", type: "goal", balance: "40.00"}, frame) assert [%{type: :goal, balance: balance}] = Budgets.list_budgets(scope) assert Decimal.eq?(balance, "40.00") diff --git a/lib/spendable_web/mcp/tools/list_transactions.ex b/lib/spendable_web/mcp/tools/list_transactions.ex index 588faa5d..f2bf2781 100644 --- a/lib/spendable_web/mcp/tools/list_transactions.ex +++ b/lib/spendable_web/mcp/tools/list_transactions.ex @@ -3,7 +3,9 @@ defmodule SpendableWeb.MCP.Tools.ListTransactions do Lists the user's transactions newest first, with how each one is allocated across budgets. A transaction is negative when money left the user and positive when it arrived, and is always fully allocated - whatever is not assigned elsewhere sits in the Spendable budget. Transactions - the user has already reviewed, or excluded from spending, are left out unless asked for. + the user has already reviewed, or excluded from spending, are left out unless asked for. A + `transfer_id` is the other side of a move between the user's own accounts, and counts as neither + spending nor income. """ use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: true} @@ -47,6 +49,7 @@ defmodule SpendableWeb.MCP.Tools.ListTransactions do note: &1.note, reviewed: &1.reviewed, excluded: &1.excluded, + transfer_id: &1.transfer_id, allocations: Enum.map( &1.budget_allocations, diff --git a/lib/spendable_web/mcp/tools/mark_transfer.ex b/lib/spendable_web/mcp/tools/mark_transfer.ex new file mode 100644 index 00000000..6e8836e1 --- /dev/null +++ b/lib/spendable_web/mcp/tools/mark_transfer.ex @@ -0,0 +1,46 @@ +defmodule SpendableWeb.MCP.Tools.MarkTransfer do + @moduledoc """ + Links two transactions as the two sides of a move between the user's own accounts. The pair has + to be one transaction leaving an account and one arriving in another, since a transfer moves + money rather than spending it. Both sides stop counting toward spending, their allocations are + cleared onto Spendable where the opposite signs cancel, and both are marked reviewed. + """ + use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false} + + import SpendableWeb.Utils.ToolReply + + alias Spendable.Transactions + + schema do + field :from_transaction_id, {:required, :string}, + description: "The id of the transaction the money left, whose amount is negative." + + field :to_transaction_id, {:required, :string}, + description: "The id of the transaction the money arrived in, whose amount is positive." + end + + @impl true + def execute(params, frame) do + scope = frame.assigns.current_scope + + with {:ok, from} <- Transactions.get_transaction(scope, id: params.from_transaction_id), + {:ok, to} <- Transactions.get_transaction(scope, id: params.to_transaction_id), + {:ok, {linked_from, linked_to}} <- Transactions.mark_as_transfer(scope, from, to) do + reply(frame, %{ + transactions: + Enum.map( + [linked_from, linked_to], + &%{ + id: &1.id, + name: &1.name, + amount: Decimal.to_string(&1.amount), + transfer_id: &1.transfer_id, + reviewed: &1.reviewed + } + ) + }) + else + {:error, reason} -> reply_error(frame, reason) + end + end +end diff --git a/lib/spendable_web/mcp/tools/mark_transfer_test.exs b/lib/spendable_web/mcp/tools/mark_transfer_test.exs new file mode 100644 index 00000000..8038ceb0 --- /dev/null +++ b/lib/spendable_web/mcp/tools/mark_transfer_test.exs @@ -0,0 +1,86 @@ +defmodule SpendableWeb.MCP.Tools.MarkTransferTest do + use Spendable.DataCase, async: true + + alias Anubis.Server.Frame + alias Anubis.Server.Response + alias Spendable.Accounts + alias Spendable.Scope + alias Spendable.Transactions + alias SpendableWeb.MCP.Tools.MarkTransfer + + setup do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + scope = Scope.for_user(user) + + {:ok, %{id: from_id} = from} = + Transactions.create_transaction(scope, %{ + "name" => "Transfer to savings", + "amount" => "-500.00", + "date" => "2026-08-15" + }) + + {:ok, %{id: to_id} = to} = + Transactions.create_transaction(scope, %{ + "name" => "Transfer from checking", + "amount" => "500.00", + "date" => "2026-08-15" + }) + + %{ + frame: Frame.new(%{current_scope: scope}), + from: from, + from_id: from_id, + to: to, + to_id: to_id, + scope: scope + } + end + + test "links both sides and marks them reviewed", %{frame: frame, from_id: from_id, to_id: to_id} do + assert {:reply, + %Response{ + structured_content: %{ + transactions: [ + %{id: ^from_id, transfer_id: ^to_id, reviewed: true}, + %{id: ^to_id, transfer_id: ^from_id, reviewed: true} + ] + } + }, ^frame} = + MarkTransfer.execute(%{from_transaction_id: from_id, to_transaction_id: to_id}, frame) + end + + test "refuses a pair moving the same way", %{frame: frame, from: from, scope: scope} do + {:ok, other} = + Transactions.create_transaction(scope, %{ + "name" => "Coffee", + "amount" => "-5.00", + "date" => "2026-08-15" + }) + + assert {:reply, %Response{isError: true, content: [%{"text" => "transfer not allowed"}]}, ^frame} = + MarkTransfer.execute( + %{from_transaction_id: from.id, to_transaction_id: other.id}, + frame + ) + end + + test "cannot reach a transaction belonging to another user", %{frame: frame, to: to} do + {:ok, other_user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + {:ok, theirs} = + Transactions.create_transaction(Scope.for_user(other_user), %{ + "name" => "Transfer to savings", + "amount" => "-500.00", + "date" => "2026-08-15" + }) + + assert {:reply, %Response{isError: true, content: [%{"text" => "transaction not found"}]}, ^frame} = + MarkTransfer.execute( + %{from_transaction_id: theirs.id, to_transaction_id: to.id}, + frame + ) + end +end diff --git a/lib/spendable_web/mcp/tools/update_budget.ex b/lib/spendable_web/mcp/tools/update_budget.ex index efa22d63..a6d01d83 100644 --- a/lib/spendable_web/mcp/tools/update_budget.ex +++ b/lib/spendable_web/mcp/tools/update_budget.ex @@ -14,7 +14,8 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do field :budget_id, {:required, :string}, description: "The id of the budget to change." field :name, :string, description: "What the budget is called, e.g. \"Groceries\"." - field :type, {:enum, [:envelope, :goal, :tracking]}, + # Strings, not atoms: the value arrives from JSON and is compared before anything casts it. + field :type, {:enum, ["envelope", "goal", "tracking"]}, description: "envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <> "without reserving anything." diff --git a/lib/spendable_web/mcp/tools/update_budget_test.exs b/lib/spendable_web/mcp/tools/update_budget_test.exs index 273fc424..bb618df2 100644 --- a/lib/spendable_web/mcp/tools/update_budget_test.exs +++ b/lib/spendable_web/mcp/tools/update_budget_test.exs @@ -33,6 +33,13 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudgetTest do assert Decimal.eq?(balance, "40.00") end + test "turns an envelope into a tracking budget", %{budget: budget, frame: frame, scope: scope} do + assert {:reply, %Response{structured_content: %{budget: %{type: :tracking}}}, ^frame} = + UpdateBudget.execute(%{budget_id: budget.id, type: "tracking"}, frame) + + assert [%{type: :tracking}] = Budgets.list_budgets(scope) + end + test "cannot reach a budget belonging to another user", %{frame: frame} do {:ok, other_user} = Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) diff --git a/lib/spendable_web/utils/budget_card.ex b/lib/spendable_web/utils/budget_card.ex index 11caec74..3b095d80 100644 --- a/lib/spendable_web/utils/budget_card.ex +++ b/lib/spendable_web/utils/budget_card.ex @@ -18,11 +18,11 @@ defmodule SpendableWeb.Utils.BudgetCard do end def build_budget_card(%Budget{type: :tracking}, spent, _current_month_is_selected) do - %{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: "No limit set"} + %{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: nil} end def build_budget_card(%Budget{type: :envelope, budgeted_amount: nil} = budget, _spent, _current) do - %{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: "No limit set"} + %{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: nil} end def build_budget_card(%Budget{type: :envelope} = budget, spent, _current_month_is_selected) do diff --git a/mobile/assets/icons/apple-logo.svg b/mobile/assets/icons/apple-logo.svg new file mode 100644 index 00000000..521bf8a7 --- /dev/null +++ b/mobile/assets/icons/apple-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 113edb64..84f82808 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -7,17 +7,31 @@ import 'banks/plaid_oauth_links.dart'; import 'design/theme.dart'; import 'shell.dart'; -class SpendableApp extends ConsumerWidget { +class SpendableApp extends ConsumerStatefulWidget { const SpendableApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SpendableAppState(); +} + +class _SpendableAppState extends ConsumerState { + final _navigator = GlobalKey(); + + @override + Widget build(BuildContext context) { final auth = ref.watch(authStateProvider); // Nothing renders it; it just has to be alive to catch a bank's OAuth redirect on launch. ref.watch(plaidOAuthResumeProvider); + // Signing out swaps the home screen underneath whatever was pushed on top of it, so the + // account screen would otherwise stay up over the sign-in screen. + ref.listen(authStateProvider, (_, next) { + if (next.value == false) _navigator.currentState?.popUntil((route) => route.isFirst); + }); + return MaterialApp( + navigatorKey: _navigator, title: 'Spendable', theme: spendableTheme(Brightness.light), darkTheme: spendableTheme(Brightness.dark), diff --git a/mobile/lib/banks/account_label.dart b/mobile/lib/banks/account_label.dart new file mode 100644 index 00000000..42af08e5 --- /dev/null +++ b/mobile/lib/banks/account_label.dart @@ -0,0 +1,5 @@ +/// An account reads as its name and the last few digits of its number. Not every account has one - +/// an Apple Cash balance has nothing to print - and dots with nothing after them say less than the +/// name on its own. +String accountLabel(String name, String? number) => + number == null || number.isEmpty ? name : '$name ••••$number'; diff --git a/mobile/lib/banks/banks_providers.dart b/mobile/lib/banks/banks_providers.dart index 2a21e1f6..57975f17 100644 --- a/mobile/lib/banks/banks_providers.dart +++ b/mobile/lib/banks/banks_providers.dart @@ -8,6 +8,9 @@ import '../api/api_error.dart'; part 'banks_providers.g.dart'; +/// The one connection whose accounts the device reads out of Wallet rather than Plaid supplying. +const financeKitProvider = 'FinanceKit'; + @riverpod Future> bankMembers(Ref ref) async { final response = await ref.watch(apiProvider).getBanksApi().listBanks().orApiError(); diff --git a/mobile/lib/banks/banks_screen.dart b/mobile/lib/banks/banks_screen.dart index d8a3d2bb..6e80925c 100644 --- a/mobile/lib/banks/banks_screen.dart +++ b/mobile/lib/banks/banks_screen.dart @@ -16,6 +16,7 @@ import '../design/tokens.dart'; import '../design/typography.dart'; import '../finance_kit/wallet_sync.dart'; import '../money.dart'; +import 'account_label.dart'; import 'banks_controller.dart'; import 'banks_providers.dart'; @@ -103,9 +104,16 @@ class _MemberState extends ConsumerState<_Member> { SizedBox( width: 32, height: 32, - child: member.hasLogo - ? _Logo(memberId: member.id) - : GlyphIcon(Glyph.bank, size: 24, color: colors.secondary), + // Wallet is not an institution Plaid has a logo for, so Apple's own mark stands in. + child: switch (member) { + _ when member.provider == financeKitProvider => GlyphIcon( + Glyph.appleLogo, + size: 24, + color: colors.primary, + ), + _ when member.hasLogo => _Logo(memberId: member.id), + _ => GlyphIcon(Glyph.bank, size: 24, color: colors.secondary), + }, ), const SizedBox(width: SpendableSpace.step), Expanded( @@ -131,7 +139,8 @@ class _MemberState extends ConsumerState<_Member> { ), ), if (_open) - for (final account in member.bankAccounts) _Account(account: account), + for (final account in member.bankAccounts) + _Account(account: account, fromWallet: member.provider == financeKitProvider), ], ); } @@ -152,9 +161,18 @@ class _Logo extends ConsumerWidget { } class _Account extends ConsumerWidget { - const _Account({required this.account}); + const _Account({required this.account, required this.fromWallet}); + + /// Apple Card, Apple Cash and Apple Savings all arrive under the one Apple connection and read + /// alike, so each one carries the glyph for what it is. + static const _walletGlyphs = { + 'credit card': Glyph.creditCard, + 'checking': Glyph.wallet, + 'savings': Glyph.bank, + }; final BankAccount account; + final bool fromWallet; @override Widget build(BuildContext context, WidgetRef ref) { @@ -177,12 +195,20 @@ class _Account extends ConsumerWidget { children: [ Row( children: [ + if (fromWallet) ...[ + GlyphIcon( + _walletGlyphs[account.subType] ?? Glyph.appleLogo, + size: 20, + color: colors.secondary, + ), + const SizedBox(width: SpendableSpace.tight), + ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${account.name} ••••${account.number ?? ''}', + accountLabel(account.name, account.number), style: SpendableType.body.copyWith(color: colors.primary), overflow: TextOverflow.ellipsis, ), diff --git a/mobile/lib/budgets/budget_card.dart b/mobile/lib/budgets/budget_card.dart index ad3d4070..ce834467 100644 --- a/mobile/lib/budgets/budget_card.dart +++ b/mobile/lib/budgets/budget_card.dart @@ -16,7 +16,7 @@ class BudgetCard { if (!currentMonth) return BudgetCard(amount: spent, label: 'SPENT'); if (budget.type == BudgetTypeEnum.tracking) { - return BudgetCard(amount: spent, label: 'SPENT', footer: 'No limit set'); + return BudgetCard(amount: spent, label: 'SPENT'); } final goal = budget.type == BudgetTypeEnum.goal; @@ -26,7 +26,7 @@ class BudgetCard { if (budgeted == null) { return goal ? BudgetCard(amount: balance, label: 'SAVED', footer: 'No goal set') - : BudgetCard(amount: balance, label: 'LEFT', footer: 'No limit set'); + : BudgetCard(amount: balance, label: 'LEFT'); } if (goal) { diff --git a/mobile/lib/budgets/budgets_providers.dart b/mobile/lib/budgets/budgets_providers.dart index bd7d74d4..ecd9d123 100644 --- a/mobile/lib/budgets/budgets_providers.dart +++ b/mobile/lib/budgets/budgets_providers.dart @@ -10,6 +10,12 @@ part 'budgets_providers.g.dart'; /// The id given to the synthetic Credit Cards card, which is not a budget and has no row. const creditCardsId = 'credit-cards'; +/// Where unallocated money lands. The server names it and sorts it first; neither it nor the +/// credit card total is a row anyone edits. +const spendableName = 'Spendable'; + +bool isEditable(Budget budget) => budget.id != creditCardsId && budget.name != spendableName; + /// Null leaves the choice to the server, which answers for the current month. @riverpod class SelectedMonth extends _$SelectedMonth { @@ -41,7 +47,20 @@ Future budgetSummary(Ref ref) async { /// Card debt is not a budget, but it reads as one on this screen: a negative balance to cover. /// It only makes sense against the current month, since it is what is owed right now. List listedBudgets(BudgetSummary summary) { - if (!summary.currentMonth || summary.budgets.isEmpty) return summary.budgets.toList(); + if (summary.budgets.isEmpty) return const []; + + final spendable = summary.budgets.where((budget) => budget.name == spendableName).firstOrNull; + + // Envelopes, then goals, then what is only tracked, alphabetical inside each. Grouping them by + // what they are does the work a heading over each group would, without the headings. + final rest = summary.budgets.where((budget) => budget.id != spendable?.id).toList() + ..sort((a, b) { + final byType = _typeOrder(a.type).compareTo(_typeOrder(b.type)); + + return byType == 0 ? a.name.compareTo(b.name) : byType; + }); + + if (!summary.currentMonth) return [?spendable, ...rest]; final creditCards = Budget( (builder) => builder @@ -52,9 +71,15 @@ List listedBudgets(BudgetSummary summary) { ); // Spendable stays first; the card total sits beside it. - return [summary.budgets.first, creditCards, ...summary.budgets.skip(1)]; + return [?spendable, creditCards, ...rest]; } +int _typeOrder(BudgetTypeEnum type) => switch (type) { + BudgetTypeEnum.envelope => 0, + BudgetTypeEnum.goal => 1, + _ => 2, +}; + /// The budget picker every other screen offers. @riverpod Future> budgetOptions(Ref ref) async { diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart index 94e53078..83c4db50 100644 --- a/mobile/lib/budgets/budgets_screen.dart +++ b/mobile/lib/budgets/budgets_screen.dart @@ -239,6 +239,11 @@ class _Row extends StatelessWidget { currentMonth: summary.currentMonth, ); + // Card debt is not an envelope with something left in it, it is what is owed right now, and + // calling it an envelope in the margin is only there to satisfy the card it is built from. + final creditCards = budget.id == creditCardsId; + final label = creditCards ? 'BALANCE' : card.label; + final barColors = { CardBar.under: colors.accent, CardBar.over: colors.negative, @@ -246,8 +251,9 @@ class _Row extends StatelessWidget { }; return LedgerRow( - // The credit card total is a reading of the bank accounts, not a row anyone can edit. - onTap: budget.id == creditCardsId ? null : () => openBudgetForm(context, budget: budget), + // The credit card total reads the bank accounts and Spendable is whatever is left over. + // Neither is a row anyone edits. + onTap: isEditable(budget) ? () => openBudgetForm(context, budget: budget) : null, ruleInset: 0, progress: card.percent == null ? null : card.percent! / 100, progressColor: barColors[card.bar], @@ -277,14 +283,16 @@ class _Row extends StatelessWidget { style: SpendableType.title.copyWith(color: colors.primary), ), ), - const SizedBox(width: SpendableSpace.tight), - _Kind(type: budget.type), + if (!creditCards) ...[ + const SizedBox(width: SpendableSpace.tight), + _Kind(type: budget.type), + ], ], ), ), MoneyText(card.amount, key: Key('amount-${budget.id}'), style: SpendableType.moneyRow), const SizedBox(width: SpendableSpace.hair), - Caption(card.label), + Caption(label), ], ), if (card.footer case final footer?) ...[ diff --git a/mobile/lib/design/glass_menu.dart b/mobile/lib/design/glass_menu.dart index 8acd08e0..86e41fab 100644 --- a/mobile/lib/design/glass_menu.dart +++ b/mobile/lib/design/glass_menu.dart @@ -55,31 +55,33 @@ class _GlassMenuRoute extends PopupRoute { final colors = SpendableColors.of(context); final eased = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic); - return FadeTransition( - opacity: eased, - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: ColoredBox(color: colors.ground.withValues(alpha: 0.25)), - ), + return Stack( + children: [ + // Outside the fade: a backdrop filter that fades in samples the screen on every frame of + // the transition, which is what reads as the blur arriving late. + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: ColoredBox(color: colors.ground.withValues(alpha: 0.25)), ), ), - Positioned( - top: anchor.dy, - left: anchor.dx, - width: 280, - // However many months there are, the menu stops short of the bottom of the screen and - // scrolls the rest. - height: - (MediaQuery.sizeOf(context).height - - anchor.dy - - MediaQuery.paddingOf(context).bottom - - SpendableSpace.block) - .clamp(0.0, _maxHeight), + ), + Positioned( + top: anchor.dy, + left: anchor.dx, + width: 280, + // However many months there are, the menu stops short of the bottom of the screen and + // scrolls the rest. + height: + (MediaQuery.sizeOf(context).height - + anchor.dy - + MediaQuery.paddingOf(context).bottom - + SpendableSpace.block) + .clamp(0.0, _maxHeight), + child: FadeTransition( + opacity: eased, child: ScaleTransition( scale: Tween(begin: 0.94, end: 1.0).animate(eased), alignment: Alignment.topLeft, @@ -122,8 +124,8 @@ class _GlassMenuRoute extends PopupRoute { ), ), ), - ], - ), + ), + ], ); } } diff --git a/mobile/lib/design/glass_sheet.dart b/mobile/lib/design/glass_sheet.dart index 4d84dd47..763baabc 100644 --- a/mobile/lib/design/glass_sheet.dart +++ b/mobile/lib/design/glass_sheet.dart @@ -9,6 +9,9 @@ Future showGlassSheet(BuildContext context, WidgetBuilder builder) { return showModalBottomSheet( context: context, isScrollControlled: true, + // Without this a long sheet runs up under the status bar, where its own grabber is the part + // that ends up unreachable and there is nothing left to close it with. + useSafeArea: true, backgroundColor: Colors.transparent, barrierColor: const Color(0x59000000), builder: (context) => _GlassSheet(child: builder(context)), diff --git a/mobile/lib/design/glyph_icon.dart b/mobile/lib/design/glyph_icon.dart index 6c28dd72..be33cced 100644 --- a/mobile/lib/design/glyph_icon.dart +++ b/mobile/lib/design/glyph_icon.dart @@ -6,6 +6,7 @@ import 'tokens.dart'; /// The Phosphor glyphs, vendored under assets/icons from the same set the web app draws from so the /// two products share an icon language. enum Glyph { + appleLogo, bank, bankFill, caretDown, diff --git a/mobile/lib/transactions/transactions_controller.dart b/mobile/lib/transactions/transactions_controller.dart index 5066af60..59ab7f0e 100644 --- a/mobile/lib/transactions/transactions_controller.dart +++ b/mobile/lib/transactions/transactions_controller.dart @@ -27,21 +27,6 @@ class TransactionsController extends _$TransactionsController { Future toggleReviewed(Transaction transaction) => update(transaction, TransactionRequest((builder) => builder.reviewed = !transaction.reviewed)); - /// A transaction with one allocation splits nothing, so the row can move the whole amount to a - /// different budget in one go. - Future spendFrom(Transaction transaction, String budgetId) => update( - transaction, - TransactionRequest( - (builder) => builder.budgetAllocations = ListBuilder([ - BudgetAllocationRequest( - (line) => line - ..amount = transaction.amount - ..budgetId = budgetId, - ), - ]), - ), - ); - Future bulk({required Set ids, bool? reviewed, bool? excluded, String? budgetId}) => _write(() async { final request = BulkRequest( diff --git a/mobile/lib/transactions/transactions_screen.dart b/mobile/lib/transactions/transactions_screen.dart index 82c4fac0..bbb2889f 100644 --- a/mobile/lib/transactions/transactions_screen.dart +++ b/mobile/lib/transactions/transactions_screen.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:spendable_api/spendable_api.dart'; import '../api/api_error.dart'; +import '../banks/account_label.dart'; import '../budgets/budget_picker.dart'; import '../design/band_button.dart'; -import '../design/caption.dart'; import '../design/glass.dart'; import '../design/glass_sheet.dart'; import '../design/glyph_icon.dart'; @@ -119,130 +118,72 @@ class _Row extends ConsumerWidget { final controller = ref.read(transactionsControllerProvider.notifier); final source = transaction.source_; - // An excluded row and one already paired as a transfer are both out of the running. - final dimmed = transaction.excluded || transaction.transferId != null; - - return Slidable( - key: ValueKey(transaction.id), - startActionPane: ActionPane( - motion: const DrawerMotion(), - extentRatio: 0.3, - children: [ - _SwipeAction( - label: transaction.reviewed ? 'Unreview' : 'Review', - color: colors.positive, - onPressed: () => controller.toggleReviewed(transaction), - ), - ], - ), - endActionPane: ActionPane( - motion: const DrawerMotion(), - extentRatio: 0.35, + return LedgerRow( + key: Key('transaction-${transaction.id}'), + selected: selected, + dimmed: transaction.excluded, + onTap: () => showGlassSheet(context, (_) => TransactionDetail(transaction: transaction)), + onLongPress: () { + HapticFeedback.selectionClick(); + ref.read(selectionProvider.notifier).toggle(transaction.id); + }, + child: Row( children: [ - _SwipeAction(label: 'Spend from', color: colors.accent, onPressed: () => _spendFrom(context, ref)), - ], - ), - child: LedgerRow( - key: Key('transaction-${transaction.id}'), - selected: selected, - dimmed: dimmed, - onTap: () => showGlassSheet(context, (_) => TransactionDetail(transaction: transaction)), - onLongPress: () { - HapticFeedback.selectionClick(); - ref.read(selectionProvider.notifier).toggle(transaction.id); - }, - child: Row( - children: [ - if (selecting) ...[ - GestureDetector( - key: Key('select-${transaction.id}'), - behavior: HitTestBehavior.opaque, - onTap: () => ref.read(selectionProvider.notifier).toggle(transaction.id), - child: Padding( - padding: const EdgeInsets.only(right: SpendableSpace.step), - child: GlyphIcon( - selected ? Glyph.checkCircleFill : Glyph.circle, - size: 22, - color: selected ? colors.accent : colors.tertiary, - ), - ), - ), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - transaction.name, - overflow: TextOverflow.ellipsis, - style: SpendableType.title.copyWith(color: colors.primary), - ), - Text( - [ - shortDate(transaction.date), - if (source != null) '${source.accountName} ••••${source.accountNumber ?? ''}', - if (transaction.transferId != null) 'Transfer', - ].join(' · '), - style: SpendableType.subhead.copyWith(color: colors.secondary), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: SpendableSpace.step), - MoneyText(money(transaction.amount), style: SpendableType.moneyRow, creditIsPositive: true), + if (selecting) ...[ GestureDetector( - key: Key('reviewed-${transaction.id}'), + key: Key('select-${transaction.id}'), behavior: HitTestBehavior.opaque, - onTap: () => controller.toggleReviewed(transaction), - child: SizedBox( - width: 44, - height: 44, - child: Center( - child: GlyphIcon( - transaction.reviewed ? Glyph.checkCircleFill : Glyph.circle, - size: 22, - color: transaction.reviewed ? colors.positive : colors.tertiary, - ), + onTap: () => ref.read(selectionProvider.notifier).toggle(transaction.id), + child: Padding( + padding: const EdgeInsets.only(right: SpendableSpace.step), + child: GlyphIcon( + selected ? Glyph.checkCircleFill : Glyph.circle, + size: 22, + color: selected ? colors.accent : colors.tertiary, ), ), ), ], - ), - ), - ); - } - - Future _spendFrom(BuildContext context, WidgetRef ref) async { - final chosen = await pickBudget(context, ref); - - if (chosen == null) return; - - await ref.read(transactionsControllerProvider.notifier).bulk(ids: {transaction.id}, budgetId: chosen.id); - } -} - -class _SwipeAction extends StatelessWidget { - const _SwipeAction({required this.label, required this.color, required this.onPressed}); - - final String label; - final Color color; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - HapticFeedback.lightImpact(); - Slidable.of(context)?.close(); - onPressed(); - }, - child: ColoredBox( - color: color, - child: Center(child: Caption(label, color: SpendableColors.of(context).ground)), - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + transaction.name, + overflow: TextOverflow.ellipsis, + style: SpendableType.title.copyWith(color: colors.primary), + ), + Text( + [ + shortDate(transaction.date), + if (source != null) accountLabel(source.accountName, source.accountNumber), + if (transaction.transferId != null) 'Transfer', + ].join(' · '), + style: SpendableType.subhead.copyWith(color: colors.secondary), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: SpendableSpace.step), + MoneyText(money(transaction.amount), style: SpendableType.moneyRow, creditIsPositive: true), + GestureDetector( + key: Key('reviewed-${transaction.id}'), + behavior: HitTestBehavior.opaque, + onTap: () => controller.toggleReviewed(transaction), + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: GlyphIcon( + transaction.reviewed ? Glyph.checkCircleFill : Glyph.circle, + size: 22, + color: transaction.reviewed ? colors.positive : colors.tertiary, + ), + ), + ), + ), + ], ), ); } @@ -346,51 +287,24 @@ class _BulkActions extends ConsumerWidget { behavior: HitTestBehavior.opaque, onTap: () => ref.read(selectionProvider.notifier).clear(), child: SizedBox( - width: 50, + width: 44, child: Center(child: GlyphIcon(Glyph.x, size: 18, color: colors.secondary)), ), ), Text('${selection.length}', style: SpendableType.moneyInline.copyWith(color: colors.primary)), - // The actions do not fit across a phone, and one of them appearing only for a pair - // means the width changes as the selection does. - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - reverse: true, - child: Row( - children: [ - _Action( - actionKey: const Key('bulk-review'), - label: 'Review', - onPressed: () => controller.bulk(ids: selection, reviewed: true), - ), - _Action( - actionKey: const Key('bulk-exclude'), - label: 'Exclude', - onPressed: () => controller.bulk(ids: selection, excluded: true), - ), - _Action( - actionKey: const Key('bulk-spend-from'), - label: 'Spend from', - onPressed: () => _pick(context, ref), - ), - // A transfer is one transaction leaving an account and one arriving in another. - if (selection.length == 2) - _Action( - actionKey: const Key('bulk-transfer'), - label: 'Transfer', - onPressed: () => controller.markAsTransfer(selection), - ), - _Action( - actionKey: const Key('bulk-delete'), - label: 'Delete', - color: colors.negative, - onPressed: () => controller.deleteAll(selection), - ), - ], - ), - ), + // Three actions are what fits across a phone without the count crowding them, so the + // rest of them are a sheet away rather than scrolled off the end of the bar. + _Action( + actionKey: const Key('bulk-review'), + label: 'Review', + onPressed: () => controller.bulk(ids: selection, reviewed: true), + ), + _Action( + actionKey: const Key('bulk-spend-from'), + label: 'Spend from', + onPressed: () => _pick(context, ref), ), + _Action(actionKey: const Key('bulk-more'), label: 'More', onPressed: () => _more(context, ref)), ], ), ), @@ -403,40 +317,99 @@ class _BulkActions extends ConsumerWidget { if (chosen == null) return; - await ref.read(transactionsControllerProvider.notifier).bulk(ids: selection, budgetId: chosen.id); + await ref + .read(transactionsControllerProvider.notifier) + .bulk(ids: selection, budgetId: chosen.id, reviewed: true); + } + + Future _more(BuildContext context, WidgetRef ref) async { + final controller = ref.read(transactionsControllerProvider.notifier); + + final chosen = await showGlassSheet( + context, + (context) => _MoreActions(selection: selection, controller: controller), + ); + + chosen?.call(); + } +} + +/// What did not fit on the bar. A transfer is one transaction leaving an account and one arriving +/// in another, so it is offered only for a pair. +class _MoreActions extends StatelessWidget { + const _MoreActions({required this.selection, required this.controller}); + + final Set selection; + final TransactionsController controller; + + @override + Widget build(BuildContext context) { + final colors = SpendableColors.of(context); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LedgerRow( + key: const Key('bulk-exclude'), + onTap: () => Navigator.of(context).pop(() => controller.bulk(ids: selection, excluded: true)), + child: Text('Exclude', style: SpendableType.title.copyWith(color: colors.primary)), + ), + if (selection.length == 2) + LedgerRow( + key: const Key('bulk-transfer'), + onTap: () => Navigator.of(context).pop(() => controller.markAsTransfer(selection)), + child: Text('Mark as transfer', style: SpendableType.title.copyWith(color: colors.primary)), + ), + LedgerRow( + key: const Key('bulk-delete'), + onTap: () => Navigator.of(context).pop(() => controller.deleteAll(selection)), + child: Text('Delete', style: SpendableType.title.copyWith(color: colors.negative)), + ), + ], + ), + ); } } class _Action extends StatelessWidget { - const _Action({required this.actionKey, required this.label, required this.onPressed, this.color}); + const _Action({required this.actionKey, required this.label, required this.onPressed}); final Key actionKey; final String label; final VoidCallback onPressed; - final Color? color; @override Widget build(BuildContext context) { final colors = SpendableColors.of(context); - return Row( - children: [ - Container(width: 1, height: 22, color: colors.separator), - GestureDetector( - key: actionKey, - behavior: HitTestBehavior.opaque, - onTap: () { - HapticFeedback.lightImpact(); - onPressed(); - }, - child: Container( - height: 50, - padding: const EdgeInsets.symmetric(horizontal: SpendableSpace.step), - alignment: Alignment.center, - child: Text(label, style: SpendableType.body.copyWith(color: color ?? colors.accent)), + return Expanded( + child: Row( + children: [ + Container(width: 1, height: 22, color: colors.separator), + Expanded( + child: GestureDetector( + key: actionKey, + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + child: Container( + height: 50, + alignment: Alignment.center, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: SpendableType.body.copyWith(color: colors.accent), + ), + ), + ), ), - ), - ], + ], + ), ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f59542e2..00931839 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -366,14 +366,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" - flutter_slidable: - dependency: "direct main" - description: - name: flutter_slidable - sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 - url: "https://pub.dev" - source: hosted - version: "4.0.3" flutter_svg: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7be11e59..1047ba2e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: sdk: flutter flutter_riverpod: ^3.4.2 flutter_secure_storage: ^11.0.0 - flutter_slidable: ^4.0.3 flutter_svg: ^2.3.0 google_sign_in: ^7.2.0 plaid_flutter: ^5.2.1 diff --git a/mobile/test/budgets/budgets_screen_test.dart b/mobile/test/budgets/budgets_screen_test.dart index 998f610c..8e7a2e15 100644 --- a/mobile/test/budgets/budgets_screen_test.dart +++ b/mobile/test/budgets/budgets_screen_test.dart @@ -185,4 +185,68 @@ void main() { expect(find.byKey(const Key('budget-save')), findsNothing); }); + + // Spendable is whatever the other budgets have not claimed, so there is nothing to edit on it. + testWidgets('Spendable is not editable', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Spendable').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('budget-save')), findsNothing); + }); + + // Card debt is not an envelope with something left in it, it is what is owed right now. + testWidgets('reads the credit card total as a balance rather than what is left', (tester) async { + await _pump( + tester, + replies: {'GET /api/budgets/summary': (status: 200, body: _summary(creditCardBalance: '325.50'))}, + ); + + expect(find.text('BALANCE'), findsOneWidget); + }); + + testWidgets('no budget says it has no limit set', (tester) async { + await _pump( + tester, + replies: { + 'GET /api/budgets/summary': ( + status: 200, + body: _summary( + budgets: [ + _budget('bgt_spendable', 'Spendable'), + _budget('bgt_amazon', 'Amazon', type: 'tracking'), + ], + ), + ), + }, + ); + + expect(find.text('No limit set'), findsNothing); + }); + + // Envelopes, then goals, then what is only tracked - the grouping does the work a heading would. + testWidgets('orders the budgets by type with no heading over each group', (tester) async { + await _pump( + tester, + replies: { + 'GET /api/budgets/summary': ( + status: 200, + body: _summary( + budgets: [ + _budget('bgt_spendable', 'Spendable'), + _budget('bgt_amazon', 'Amazon', type: 'tracking'), + _budget('bgt_vacation', 'Vacation', type: 'goal'), + _budget('bgt_rent', 'Rent'), + _budget('bgt_food', 'Food'), + ], + ), + ), + }, + ); + + final rows = ['Food', 'Rent', 'Vacation', 'Amazon'].map((name) => tester.getTopLeft(find.text(name)).dy); + + expect(rows, orderedEquals(rows.toList()..sort())); + }); } diff --git a/mobile/test/design/layout_test.dart b/mobile/test/design/layout_test.dart index 227869a7..4905009a 100644 --- a/mobile/test/design/layout_test.dart +++ b/mobile/test/design/layout_test.dart @@ -176,14 +176,18 @@ void main() { await tester.tapAt(const Offset(200, 60)); await tester.pumpAndSettle(); - await tester.tap(find.text('Emergency fund')); + await tester.tap(find.byKey(const Key('open-account'))); await tester.pumpAndSettle(); - await tester.tapAt(const Offset(200, 60)); + await tester.tap(find.byKey(const Key('account-back'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('open-account'))); + // Last, because reaching a row further down the list scrolls the large title away and the + // band's own buttons go with it. + await tester.ensureVisible(find.text('Emergency fund')); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('account-back'))); + await tester.tap(find.text('Emergency fund')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 60)); await tester.pumpAndSettle(); // The transaction detail sheet, the filters sheet, and the bulk bar. diff --git a/mobile/test/transactions/transactions_screen_test.dart b/mobile/test/transactions/transactions_screen_test.dart index 70b7648b..dc1f5403 100644 --- a/mobile/test/transactions/transactions_screen_test.dart +++ b/mobile/test/transactions/transactions_screen_test.dart @@ -171,11 +171,18 @@ void main() { await tester.longPress(find.byKey(const Key('transaction-txn_1'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('bulk-more'))); + await tester.pumpAndSettle(); + // One transaction is not a pair, so there is nothing to link it to. expect(find.byKey(const Key('bulk-transfer')), findsNothing); + await tester.tapAt(const Offset(200, 60)); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('bulk-more'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('bulk-transfer'))); await tester.pumpAndSettle(); @@ -206,6 +213,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('bulk-more'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('bulk-transfer'))); await tester.pumpAndSettle(); @@ -270,6 +279,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('bulk-more'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('bulk-delete'))); await tester.pumpAndSettle(); diff --git a/shared/budget_cards.json b/shared/budget_cards.json index ec5e3c39..44eceb94 100644 --- a/shared/budget_cards.json +++ b/shared/budget_cards.json @@ -22,7 +22,7 @@ "label": "SPENT", "percent": null, "bar": null, - "footer": "No limit set" + "footer": null } }, { @@ -35,7 +35,7 @@ "label": "LEFT", "percent": null, "bar": null, - "footer": "No limit set" + "footer": null } }, { From 6f5d4b6850d75318fa4b35af129831fd55c79c54 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 15:40:23 -0600 Subject: [PATCH 2/4] Answer the second pass over the phone screens The budget picker read the budget list instead of watching it, so on a screen that never loaded it the sheet opened with no rows - and a sheet sized to its contents with nothing in it is a sheet that never appears. That was both the picker sticking at the bottom and Spend from doing nothing. It watches now, and a test opens it from the bulk bar where nothing else loads the list. Spendable was the label over the figure and a row underneath it, the same word twice about two different numbers. The row goes on the current month and stays on a past one, where there is no figure above the list to read instead. Apple's mark comes from the system font at U+F8FF rather than a shape library's drawing of it. The compact title stays left where the large one was instead of travelling to the middle, a transaction's amount drops its red and green and a size, the line under a budget's figure sits under the figure, and the count in the bulk bar loses the rule to its right. Co-Authored-By: Claude Opus 5 --- mobile/assets/icons/apple-logo.svg | 1 - mobile/lib/banks/apple_mark.dart | 21 ++++++ mobile/lib/banks/banks_screen.dart | 13 ++-- mobile/lib/budgets/budget_picker.dart | 68 +++++++++++++------ mobile/lib/budgets/budgets_providers.dart | 7 +- mobile/lib/budgets/budgets_screen.dart | 12 +++- mobile/lib/design/glyph_icon.dart | 1 - mobile/lib/design/money_text.dart | 13 +++- mobile/lib/design/nav_band.dart | 15 ++-- mobile/lib/design/typography.dart | 9 +++ mobile/lib/splits/split_form.dart | 2 +- .../lib/transactions/transaction_detail.dart | 2 +- .../lib/transactions/transactions_screen.dart | 12 ++-- mobile/test/budgets/budgets_screen_test.dart | 20 ++++-- .../transactions_screen_test.dart | 30 ++++++++ 15 files changed, 176 insertions(+), 50 deletions(-) delete mode 100644 mobile/assets/icons/apple-logo.svg create mode 100644 mobile/lib/banks/apple_mark.dart diff --git a/mobile/assets/icons/apple-logo.svg b/mobile/assets/icons/apple-logo.svg deleted file mode 100644 index 521bf8a7..00000000 --- a/mobile/assets/icons/apple-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/mobile/lib/banks/apple_mark.dart b/mobile/lib/banks/apple_mark.dart new file mode 100644 index 00000000..c0edc64c --- /dev/null +++ b/mobile/lib/banks/apple_mark.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import '../design/tokens.dart'; + +/// Apple's own mark, taken from the system font at U+F8FF rather than drawn again from a shape +/// library. It is the real one, and it is not artwork the app has to ship or keep in step. +class AppleMark extends StatelessWidget { + const AppleMark({super.key, this.size = 22, this.color}); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + return Text( + '', + // The mark sits high in its em box, so it reads a size smaller than a glyph set beside it. + style: TextStyle(fontSize: size, color: color ?? SpendableColors.of(context).primary, height: 1.2), + ); + } +} diff --git a/mobile/lib/banks/banks_screen.dart b/mobile/lib/banks/banks_screen.dart index 6e80925c..0c066f37 100644 --- a/mobile/lib/banks/banks_screen.dart +++ b/mobile/lib/banks/banks_screen.dart @@ -17,6 +17,7 @@ import '../design/typography.dart'; import '../finance_kit/wallet_sync.dart'; import '../money.dart'; import 'account_label.dart'; +import 'apple_mark.dart'; import 'banks_controller.dart'; import 'banks_providers.dart'; @@ -106,10 +107,8 @@ class _MemberState extends ConsumerState<_Member> { height: 32, // Wallet is not an institution Plaid has a logo for, so Apple's own mark stands in. child: switch (member) { - _ when member.provider == financeKitProvider => GlyphIcon( - Glyph.appleLogo, - size: 24, - color: colors.primary, + _ when member.provider == financeKitProvider => Center( + child: AppleMark(size: 24, color: colors.primary), ), _ when member.hasLogo => _Logo(memberId: member.id), _ => GlyphIcon(Glyph.bank, size: 24, color: colors.secondary), @@ -171,6 +170,8 @@ class _Account extends ConsumerWidget { 'savings': Glyph.bank, }; + static const _fallbackGlyph = Glyph.wallet; + final BankAccount account; final bool fromWallet; @@ -197,7 +198,7 @@ class _Account extends ConsumerWidget { children: [ if (fromWallet) ...[ GlyphIcon( - _walletGlyphs[account.subType] ?? Glyph.appleLogo, + _walletGlyphs[account.subType] ?? _fallbackGlyph, size: 20, color: colors.secondary, ), @@ -230,7 +231,7 @@ class _Account extends ConsumerWidget { label: 'Assign to budget', value: budgets.where((budget) => budget.id == account.budgetId).firstOrNull?.name, onTap: () async { - final chosen = await pickBudget(context, ref); + final chosen = await pickBudget(context); if (chosen != null) await controller.assignBudget(account, chosen.id); }, diff --git a/mobile/lib/budgets/budget_picker.dart b/mobile/lib/budgets/budget_picker.dart index fd0d49a9..b2512908 100644 --- a/mobile/lib/budgets/budget_picker.dart +++ b/mobile/lib/budgets/budget_picker.dart @@ -9,25 +9,53 @@ import '../design/typography.dart'; import 'budgets_providers.dart'; /// The one way a budget gets chosen, wherever the choosing happens. -Future pickBudget(BuildContext context, WidgetRef ref) { - final budgets = ref.read(budgetOptionsProvider).value ?? const []; - - return showGlassSheet( - context, - (context) => ListView( - shrinkWrap: true, - padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), - children: [ - for (final budget in budgets) - LedgerRow( - key: Key('budget-${budget.id}'), - onTap: () => Navigator.of(context).pop(budget), - child: Text( - budget.name, - style: SpendableType.title.copyWith(color: SpendableColors.of(context).primary), +Future pickBudget(BuildContext context) => + showGlassSheet(context, (_) => const _BudgetPicker()); + +/// The list is watched rather than read: a screen that never loaded it would otherwise open a +/// sheet with nothing in it, which on a sheet sized to its contents is a sheet that never appears. +class _BudgetPicker extends ConsumerWidget { + const _BudgetPicker(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final budgets = ref.watch(budgetOptionsProvider); + final colors = SpendableColors.of(context); + + return switch (budgets) { + AsyncData(value: final budgets) when budgets.isEmpty => const _Message('No budgets yet.'), + AsyncData(value: final budgets) => ListView( + shrinkWrap: true, + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), + children: [ + for (final budget in budgets) + LedgerRow( + key: Key('budget-${budget.id}'), + onTap: () => Navigator.of(context).pop(budget), + child: Text(budget.name, style: SpendableType.title.copyWith(color: colors.primary)), ), - ), - ], - ), - ); + ], + ), + AsyncError(:final error) => _Message('$error'), + _ => const SizedBox(height: 120, child: Center(child: CircularProgressIndicator())), + }; + } +} + +class _Message extends StatelessWidget { + const _Message(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(SpendableSpace.block), + child: Text( + text, + textAlign: TextAlign.center, + style: SpendableType.body.copyWith(color: SpendableColors.of(context).secondary), + ), + ); + } } diff --git a/mobile/lib/budgets/budgets_providers.dart b/mobile/lib/budgets/budgets_providers.dart index ecd9d123..731affd7 100644 --- a/mobile/lib/budgets/budgets_providers.dart +++ b/mobile/lib/budgets/budgets_providers.dart @@ -60,6 +60,8 @@ List listedBudgets(BudgetSummary summary) { return byType == 0 ? a.name.compareTo(b.name) : byType; }); + // A past month has no Spendable figure over the list, so the budget is the only place to read + // what came out of it. if (!summary.currentMonth) return [?spendable, ...rest]; final creditCards = Budget( @@ -70,8 +72,9 @@ List listedBudgets(BudgetSummary summary) { ..balance = (-money(summary.creditCardBalance)).toString(), ); - // Spendable stays first; the card total sits beside it. - return [?spendable, creditCards, ...rest]; + // Spendable is the figure the screen opens with, so listing it again underneath only says the + // same word twice about two different numbers. The card total takes the first row instead. + return [creditCards, ...rest]; } int _typeOrder(BudgetTypeEnum type) => switch (type) { diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart index 83c4db50..628e1916 100644 --- a/mobile/lib/budgets/budgets_screen.dart +++ b/mobile/lib/budgets/budgets_screen.dart @@ -264,7 +264,7 @@ class _Row extends StatelessWidget { SpendableSpace.step, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.baseline, @@ -295,9 +295,17 @@ class _Row extends StatelessWidget { Caption(label), ], ), + // Ranged right, under the figure it explains, but laid out across the whole row so it + // never decides how much room the name gets. if (card.footer case final footer?) ...[ const SizedBox(height: 1), - Text(footer, style: SpendableType.subhead.copyWith(color: colors.secondary)), + Text( + footer, + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: SpendableType.subhead.copyWith(color: colors.secondary), + ), ], ], ), diff --git a/mobile/lib/design/glyph_icon.dart b/mobile/lib/design/glyph_icon.dart index be33cced..6c28dd72 100644 --- a/mobile/lib/design/glyph_icon.dart +++ b/mobile/lib/design/glyph_icon.dart @@ -6,7 +6,6 @@ import 'tokens.dart'; /// The Phosphor glyphs, vendored under assets/icons from the same set the web app draws from so the /// two products share an icon language. enum Glyph { - appleLogo, bank, bankFill, caretDown, diff --git a/mobile/lib/design/money_text.dart b/mobile/lib/design/money_text.dart index 6a0fb7ec..3169de87 100644 --- a/mobile/lib/design/money_text.dart +++ b/mobile/lib/design/money_text.dart @@ -7,17 +7,28 @@ import 'tokens.dart'; /// An amount, set in tabular figures and coloured by its sign. Positive money is only ever green /// where being in the black is the point; everywhere else it reads as ordinary text. class MoneyText extends StatelessWidget { - const MoneyText(this.amount, {super.key, required this.style, this.creditIsPositive = false}); + const MoneyText( + this.amount, { + super.key, + required this.style, + this.creditIsPositive = false, + this.neutral = false, + }); final Decimal amount; final TextStyle style; final bool creditIsPositive; + /// Set where a list of amounts is the whole screen and colouring every one of them by sign says + /// nothing - the sign is already in the figure. + final bool neutral; + @override Widget build(BuildContext context) { final colors = SpendableColors.of(context); final color = switch (amount.sign) { + _ when neutral => colors.primary, < 0 => colors.negative, _ => creditIsPositive ? colors.positive : colors.primary, }; diff --git a/mobile/lib/design/nav_band.dart b/mobile/lib/design/nav_band.dart index 1e66626a..5ecc6ec9 100644 --- a/mobile/lib/design/nav_band.dart +++ b/mobile/lib/design/nav_band.dart @@ -93,16 +93,19 @@ class _NavBandDelegate extends SliverPersistentHeaderDelegate { ? const SizedBox.shrink() : Opacity( opacity: compact, - child: Text( - title, - textAlign: TextAlign.center, - style: SpendableType.title.copyWith(color: colors.primary), + // Left, where the large title it replaces was, so the title does not travel + // across the band as the list scrolls under it. + child: Padding( + padding: const EdgeInsets.only(left: SpendableSpace.tight), + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: SpendableType.title.copyWith(color: colors.primary), + ), ), ), ), ...actions, - // Keeps the compact title centred on the screen rather than on what is left of the row. - if (leading != null && actions.isEmpty) const SizedBox(width: 44), ], ), ), diff --git a/mobile/lib/design/typography.dart b/mobile/lib/design/typography.dart index 79c4d480..600347ef 100644 --- a/mobile/lib/design/typography.dart +++ b/mobile/lib/design/typography.dart @@ -30,6 +30,15 @@ abstract final class SpendableType { fontFeatures: _tabular, ); + /// A transaction's amount, a step down from a budget's because it sits beside a name and a date + /// rather than heading a card of its own. + static const moneyListRow = TextStyle( + fontSize: 21, + fontWeight: FontWeight.w500, + letterSpacing: -0.46, + fontFeatures: _tabular, + ); + /// A figure that has to line up in a column but is not the row's headline. static const moneyInline = TextStyle( fontSize: 15, diff --git a/mobile/lib/splits/split_form.dart b/mobile/lib/splits/split_form.dart index 79d71393..053fe876 100644 --- a/mobile/lib/splits/split_form.dart +++ b/mobile/lib/splits/split_form.dart @@ -105,7 +105,7 @@ class _SplitFormState extends ConsumerState { label: 'Budget', value: budgets.where((budget) => budget.id == line.budgetId).firstOrNull?.name, onTap: () async { - final chosen = await pickBudget(context, ref); + final chosen = await pickBudget(context); if (chosen != null) setState(() => line.budgetId = chosen.id); }, diff --git a/mobile/lib/transactions/transaction_detail.dart b/mobile/lib/transactions/transaction_detail.dart index e5d6b9d8..24331cb8 100644 --- a/mobile/lib/transactions/transaction_detail.dart +++ b/mobile/lib/transactions/transaction_detail.dart @@ -202,7 +202,7 @@ class _TransactionDetailState extends ConsumerState { label: 'Budget', value: budgets.where((budget) => budget.id == line.budgetId).firstOrNull?.name, onTap: () async { - final chosen = await pickBudget(context, ref); + final chosen = await pickBudget(context); if (chosen != null) setState(() => line.budgetId = chosen.id); }, diff --git a/mobile/lib/transactions/transactions_screen.dart b/mobile/lib/transactions/transactions_screen.dart index bbb2889f..8e3c741e 100644 --- a/mobile/lib/transactions/transactions_screen.dart +++ b/mobile/lib/transactions/transactions_screen.dart @@ -166,7 +166,7 @@ class _Row extends ConsumerWidget { ), ), const SizedBox(width: SpendableSpace.step), - MoneyText(money(transaction.amount), style: SpendableType.moneyRow, creditIsPositive: true), + MoneyText(money(transaction.amount), style: SpendableType.moneyListRow, neutral: true), GestureDetector( key: Key('reviewed-${transaction.id}'), behavior: HitTestBehavior.opaque, @@ -297,6 +297,7 @@ class _BulkActions extends ConsumerWidget { _Action( actionKey: const Key('bulk-review'), label: 'Review', + divided: false, onPressed: () => controller.bulk(ids: selection, reviewed: true), ), _Action( @@ -313,7 +314,7 @@ class _BulkActions extends ConsumerWidget { } Future _pick(BuildContext context, WidgetRef ref) async { - final chosen = await pickBudget(context, ref); + final chosen = await pickBudget(context); if (chosen == null) return; @@ -374,12 +375,15 @@ class _MoreActions extends StatelessWidget { } class _Action extends StatelessWidget { - const _Action({required this.actionKey, required this.label, required this.onPressed}); + const _Action({required this.actionKey, required this.label, required this.onPressed, this.divided = true}); final Key actionKey; final String label; final VoidCallback onPressed; + /// The first action sits against the count, which is not another action to be ruled off from. + final bool divided; + @override Widget build(BuildContext context) { final colors = SpendableColors.of(context); @@ -387,7 +391,7 @@ class _Action extends StatelessWidget { return Expanded( child: Row( children: [ - Container(width: 1, height: 22, color: colors.separator), + if (divided) Container(width: 1, height: 22, color: colors.separator), Expanded( child: GestureDetector( key: actionKey, diff --git a/mobile/test/budgets/budgets_screen_test.dart b/mobile/test/budgets/budgets_screen_test.dart index 8e7a2e15..ace3f588 100644 --- a/mobile/test/budgets/budgets_screen_test.dart +++ b/mobile/test/budgets/budgets_screen_test.dart @@ -186,14 +186,24 @@ void main() { expect(find.byKey(const Key('budget-save')), findsNothing); }); - // Spendable is whatever the other budgets have not claimed, so there is nothing to edit on it. - testWidgets('Spendable is not editable', (tester) async { + // The figure over the list is Spendable, so a row saying it again is the same word twice about + // two different numbers. + testWidgets('says Spendable once on the current month', (tester) async { await _pump(tester); - await tester.tap(find.text('Spendable').last); - await tester.pumpAndSettle(); + expect(find.text('Spendable'), findsNothing); + expect(find.byKey(const Key('spendable-total')), findsOneWidget); + }); - expect(find.byKey(const Key('budget-save')), findsNothing); + // A past month has no figure over the list, so the budget is the only place left to read it. + testWidgets('keeps the Spendable row on a past month', (tester) async { + await _pump( + tester, + replies: {'GET /api/budgets/summary': (status: 200, body: _summary(currentMonth: false))}, + ); + + expect(find.text('Spendable'), findsOneWidget); + expect(find.byKey(const Key('spendable-total')), findsNothing); }); // Card debt is not an envelope with something left in it, it is what is owed right now. diff --git a/mobile/test/transactions/transactions_screen_test.dart b/mobile/test/transactions/transactions_screen_test.dart index dc1f5403..1adc98bf 100644 --- a/mobile/test/transactions/transactions_screen_test.dart +++ b/mobile/test/transactions/transactions_screen_test.dart @@ -221,6 +221,36 @@ void main() { expect(find.text('needs one leaving and one arriving'), findsOneWidget); }); + // Nothing on this screen loads the budget list, so a picker that only read it opened a sheet + // with no rows in it - and a sheet sized to its contents with no contents never appears. + testWidgets('spend from opens the budget picker and marks what it moves reviewed', (tester) async { + final api = await _pump( + tester, + replies: _replies({ + 'PATCH /api/transactions/bulk': ( + status: 200, + body: { + 'transactions': [_transaction('txn_1', 'Market', reviewed: true)], + 'failed': [], + }, + ), + }), + ); + + await tester.longPress(find.byKey(const Key('transaction-txn_1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('bulk-spend-from'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('budget-bgt_fun')), findsOneWidget); + + await tester.tap(find.byKey(const Key('budget-bgt_fun'))); + await tester.pumpAndSettle(); + + expect(api.requests.last.data, containsPair('budget_id', 'bgt_fun')); + expect(api.requests.last.data, containsPair('reviewed', true)); + }); + testWidgets('bulk review applies to everything selected and clears the selection', (tester) async { final api = await _pump( tester, From 234290c13287d9793a0b2eb35bfaa9502c1c3df6 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 15:49:19 -0600 Subject: [PATCH 3/4] Set a budget's label under its figure, not the line explaining it The line reading what was spent of what was budgeted belongs under the name it is about. It is LEFT, TO GO and BALANCE that move under the amount. Co-Authored-By: Claude Opus 5 --- mobile/lib/budgets/budgets_screen.dart | 47 ++++++++++++++------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart index 628e1916..64c49a79 100644 --- a/mobile/lib/budgets/budgets_screen.dart +++ b/mobile/lib/budgets/budgets_screen.dart @@ -263,15 +263,14 @@ class _Row extends StatelessWidget { SpendableSpace.gutter, SpendableSpace.step, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Expanded( - child: Row( + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, @@ -289,24 +288,28 @@ class _Row extends StatelessWidget { ], ], ), - ), + if (card.footer case final footer?) ...[ + const SizedBox(height: 1), + Text( + footer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: SpendableType.subhead.copyWith(color: colors.secondary), + ), + ], + ], + ), + ), + const SizedBox(width: SpendableSpace.step), + // What the figure is stands under it rather than beside it, so the eye reads the number + // first and the word only if it needs to. + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ MoneyText(card.amount, key: Key('amount-${budget.id}'), style: SpendableType.moneyRow), - const SizedBox(width: SpendableSpace.hair), Caption(label), ], ), - // Ranged right, under the figure it explains, but laid out across the whole row so it - // never decides how much room the name gets. - if (card.footer case final footer?) ...[ - const SizedBox(height: 1), - Text( - footer, - textAlign: TextAlign.end, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: SpendableType.subhead.copyWith(color: colors.secondary), - ), - ], ], ), ); From 035569f53265f07cad296d89663f6cbdfb708421 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 15:53:49 -0600 Subject: [PATCH 4/4] Regenerate the transactions controller Dropping spendFrom changed the provider hash, and the .g.dart was left behind. Co-Authored-By: Claude Opus 5 --- mobile/lib/transactions/transactions_controller.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/transactions/transactions_controller.g.dart b/mobile/lib/transactions/transactions_controller.g.dart index 045b1c26..4641fcf8 100644 --- a/mobile/lib/transactions/transactions_controller.g.dart +++ b/mobile/lib/transactions/transactions_controller.g.dart @@ -51,7 +51,7 @@ final class TransactionsControllerProvider } String _$transactionsControllerHash() => - r'684e2fcfec358b2f01f0a8cc8dc555cd96fa1a24'; + r'8bff76a2e6a6e703215fee2301e3b54ff42f05aa'; /// Writing transactions. Every write renders the transaction that came back rather than what was /// sent: the server re-runs the allocation split on each save, so the response is the only