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/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/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_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..0c066f37 100644 --- a/mobile/lib/banks/banks_screen.dart +++ b/mobile/lib/banks/banks_screen.dart @@ -16,6 +16,8 @@ import '../design/tokens.dart'; 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'; @@ -103,9 +105,14 @@ 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 => Center( + child: AppleMark(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 +138,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 +160,20 @@ 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, + }; + + static const _fallbackGlyph = Glyph.wallet; final BankAccount account; + final bool fromWallet; @override Widget build(BuildContext context, WidgetRef ref) { @@ -177,12 +196,20 @@ class _Account extends ConsumerWidget { children: [ Row( children: [ + if (fromWallet) ...[ + GlyphIcon( + _walletGlyphs[account.subType] ?? _fallbackGlyph, + 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, ), @@ -204,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_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/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 bd7d74d4..731affd7 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,22 @@ 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; + }); + + // 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( (builder) => builder @@ -51,10 +72,17 @@ List listedBudgets(BudgetSummary summary) { ..balance = (-money(summary.creditCardBalance)).toString(), ); - // Spendable stays first; the card total sits beside it. - return [summary.budgets.first, creditCards, ...summary.budgets.skip(1)]; + // 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) { + 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..64c49a79 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], @@ -257,15 +263,14 @@ class _Row extends StatelessWidget { SpendableSpace.gutter, SpendableSpace.step, ), - child: Column( + 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, @@ -277,20 +282,34 @@ 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), + ], ], ), - ), + 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(card.label), + Caption(label), ], ), - if (card.footer case final footer?) ...[ - const SizedBox(height: 1), - Text(footer, style: SpendableType.subhead.copyWith(color: colors.secondary)), - ], ], ), ); 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/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_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_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 diff --git a/mobile/lib/transactions/transactions_screen.dart b/mobile/lib/transactions/transactions_screen.dart index 82c4fac0..8e3c741e 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.moneyListRow, neutral: 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,25 @@ 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', + divided: false, + 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)), ], ), ), @@ -399,44 +314,106 @@ 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; - 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, this.divided = true}); final Key actionKey; final String label; final VoidCallback onPressed; - final Color? color; + + /// 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); - 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: [ + if (divided) 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..ace3f588 100644 --- a/mobile/test/budgets/budgets_screen_test.dart +++ b/mobile/test/budgets/budgets_screen_test.dart @@ -185,4 +185,78 @@ void main() { expect(find.byKey(const Key('budget-save')), findsNothing); }); + + // 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); + + expect(find.text('Spendable'), findsNothing); + expect(find.byKey(const Key('spendable-total')), findsOneWidget); + }); + + // 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. + 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..1adc98bf 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,12 +213,44 @@ 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(); 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, @@ -270,6 +309,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 } }, {