diff --git a/mobile/assets/icons/bank-fill.svg b/mobile/assets/icons/bank-fill.svg
new file mode 100644
index 00000000..7e27e1e3
--- /dev/null
+++ b/mobile/assets/icons/bank-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/bank.svg b/mobile/assets/icons/bank.svg
new file mode 100644
index 00000000..d7a708bd
--- /dev/null
+++ b/mobile/assets/icons/bank.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/caret-down.svg b/mobile/assets/icons/caret-down.svg
new file mode 100644
index 00000000..c6e84e84
--- /dev/null
+++ b/mobile/assets/icons/caret-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/caret-left.svg b/mobile/assets/icons/caret-left.svg
new file mode 100644
index 00000000..c68102c9
--- /dev/null
+++ b/mobile/assets/icons/caret-left.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/caret-right.svg b/mobile/assets/icons/caret-right.svg
new file mode 100644
index 00000000..d73dd5d9
--- /dev/null
+++ b/mobile/assets/icons/caret-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/check-circle-fill.svg b/mobile/assets/icons/check-circle-fill.svg
new file mode 100644
index 00000000..ee1317cf
--- /dev/null
+++ b/mobile/assets/icons/check-circle-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/circle.svg b/mobile/assets/icons/circle.svg
new file mode 100644
index 00000000..e7b12e1a
--- /dev/null
+++ b/mobile/assets/icons/circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/copy-fill.svg b/mobile/assets/icons/copy-fill.svg
new file mode 100644
index 00000000..bcd73b06
--- /dev/null
+++ b/mobile/assets/icons/copy-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/copy.svg b/mobile/assets/icons/copy.svg
new file mode 100644
index 00000000..8f3dd2ec
--- /dev/null
+++ b/mobile/assets/icons/copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/credit-card-fill.svg b/mobile/assets/icons/credit-card-fill.svg
new file mode 100644
index 00000000..6aa64a1d
--- /dev/null
+++ b/mobile/assets/icons/credit-card-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/credit-card.svg b/mobile/assets/icons/credit-card.svg
new file mode 100644
index 00000000..da19de90
--- /dev/null
+++ b/mobile/assets/icons/credit-card.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/funnel.svg b/mobile/assets/icons/funnel.svg
new file mode 100644
index 00000000..fd000965
--- /dev/null
+++ b/mobile/assets/icons/funnel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/minus-circle.svg b/mobile/assets/icons/minus-circle.svg
new file mode 100644
index 00000000..1b85909d
--- /dev/null
+++ b/mobile/assets/icons/minus-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/money-fill.svg b/mobile/assets/icons/money-fill.svg
new file mode 100644
index 00000000..6cf2ee0f
--- /dev/null
+++ b/mobile/assets/icons/money-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/money.svg b/mobile/assets/icons/money.svg
new file mode 100644
index 00000000..8e33f05a
--- /dev/null
+++ b/mobile/assets/icons/money.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/plus.svg b/mobile/assets/icons/plus.svg
new file mode 100644
index 00000000..25ad5e32
--- /dev/null
+++ b/mobile/assets/icons/plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/trash.svg b/mobile/assets/icons/trash.svg
new file mode 100644
index 00000000..9f963931
--- /dev/null
+++ b/mobile/assets/icons/trash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/user.svg b/mobile/assets/icons/user.svg
new file mode 100644
index 00000000..d560c5d5
--- /dev/null
+++ b/mobile/assets/icons/user.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/wallet.svg b/mobile/assets/icons/wallet.svg
new file mode 100644
index 00000000..3942a1cc
--- /dev/null
+++ b/mobile/assets/icons/wallet.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/assets/icons/x.svg b/mobile/assets/icons/x.svg
new file mode 100644
index 00000000..2ee41d11
--- /dev/null
+++ b/mobile/assets/icons/x.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mobile/docs/design.md b/mobile/docs/design.md
new file mode 100644
index 00000000..e5b35470
--- /dev/null
+++ b/mobile/docs/design.md
@@ -0,0 +1,66 @@
+# Design
+
+Editorial content, glass controls. The content layer is flat and hairline-ruled - no cards, no
+raised surfaces, no corner radius. Every control floats above it as a pane of glass: blurred,
+tinted, lit along its top edge. The two layers never borrow each other's treatment.
+
+Both appearances are first class and follow the system.
+
+## Where it lives
+
+`lib/design/` holds the system; a screen should reach for it before inventing anything.
+
+| File | What it is |
+| --- | --- |
+| `tokens.dart` | Colours per appearance, and the spacing, radius, blur and chrome metrics |
+| `typography.dart` | The type ramp. Money always sets in tabular figures |
+| `theme.dart` | `ThemeData` for the Material widgets that remain - fields, switches, pickers |
+| `glass.dart` | `GlassPanel`: blur, tint, lit edge, ring, shadow. Every control is one |
+| `ledger_screen.dart` | The shape of a screen: band, bouncing list, floating bottom bar |
+| `nav_band.dart` | Large title that collapses into a glass band with a compact title |
+| `ledger_row.dart` | A row and the rule it closes with, which thickens into its progress bar |
+| `glass_tab_bar.dart`, `glass_sheet.dart`, `glass_menu.dart` | The three pieces of chrome |
+| `glyph_icon.dart` | Phosphor glyphs, vendored under `assets/icons` from the set the web app ships |
+
+## Rules worth stating
+
+- **Colour comes from `SpendableColors.of(context)`, never from a literal.** It resolves off
+ brightness alone, so a screen mounted without the app's theme still reads right.
+- **Money goes through `MoneyText`.** It sets tabular figures and colours by sign; `creditIsPositive`
+ is for the places where being in the black is the point.
+- **A row is a `LedgerRow`.** Progress is `progress` on that row, not a separate bar.
+- **A caption is a `Caption`.** It uppercases; do not pass text that is already uppercase.
+- **A choice opens a sheet, not a dropdown.** `PickerField` reads like a field and opens one.
+- **Selection is entered by long press**, and the leading circles only exist while it is on.
+- **Anything drawn in a route of its own needs a `Material` above it.** Text without one falls back
+ to the framework's yellow-underlined error style - which is what the tab bar and the month menu
+ each did until they were given one.
+
+## Metrics
+
+The blur is `ImageFilterConfig.blur(bounded: true)` rather than a plain `ImageFilter.blur`. A
+bounded blur samples only what is behind the pane; an unbounded one pulls in transparent black from
+outside its edges, which is what makes an ordinary `BackdropFilter` read as a washed-out rectangle
+instead of glass. `Shell` wraps the app in a `BackdropGroup` so the panes on a screen sample the
+backdrop once between them.
+
+Spacing 4 / 7 / 13 / 17 / 24 in content. Chrome insets 14 at the sides and 24 at the bottom. Radii
+are 0 on every row and rail, 31 on the tab bar, 25 on a capsule, 16 on a menu or sheet. Blur is 26
+on the band, 28 on the tab bar, 30 on menus and sheets, all at saturate(180%).
+
+The tab bar is 62 tall and floats 24 above the bottom of the screen - over the home indicator's
+margin rather than stacked on top of it, which is where iOS puts a floating bar. `Shell` hands the
+clearance down as bottom padding so lists scroll under it rather than stopping above it.
+
+There is no native glass to call on: nothing in the Flutter SDK provides it, and its own
+`CupertinoTabBar` is a full-width bar with a solid 1px grey top border. The lit edge here fades out
+towards the ends for the same reason - an even line across the top reads as a drawn border, which is
+the tell that a pane is not really glass.
+
+## Seeing it
+
+The simulator runtime is not installed yet, so the way to look at a screen is to render it from the
+test tree: pump `Shell` at 390x844 with `/System/Library/Fonts/SFNS.ttf` loaded through a
+`FontLoader` (the test renderer draws every glyph as a box without it), then
+`expectLater(find.byType(Shell), matchesGoldenFile(...))` with `--update-goldens`. It is close
+enough to read the layout and the colour, and it costs nothing to throw away afterwards.
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index b4ae1476..113edb64 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -4,8 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth/auth_controller.dart';
import 'auth/sign_in_screen.dart';
import 'banks/plaid_oauth_links.dart';
+import 'design/theme.dart';
import 'shell.dart';
-import 'theme.dart';
class SpendableApp extends ConsumerWidget {
const SpendableApp({super.key});
@@ -19,7 +19,9 @@ class SpendableApp extends ConsumerWidget {
return MaterialApp(
title: 'Spendable',
- theme: spendableTheme(),
+ theme: spendableTheme(Brightness.light),
+ darkTheme: spendableTheme(Brightness.dark),
+ themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
// Null only while the first Keychain read is in flight. Signing in and out never puts this
// back into loading, so the screen cannot fall back to the splash mid-flow.
diff --git a/mobile/lib/auth/account_screen.dart b/mobile/lib/auth/account_screen.dart
index 9bf5259c..7e1f3873 100644
--- a/mobile/lib/auth/account_screen.dart
+++ b/mobile/lib/auth/account_screen.dart
@@ -2,7 +2,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
-import '../theme.dart';
+import '../design/band_button.dart';
+import '../design/caption.dart';
+import '../design/glyph_icon.dart';
+import '../design/ledger_row.dart';
+import '../design/ledger_screen.dart';
+import '../design/nav_band.dart';
+import '../design/section_rule.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import 'auth_controller.dart';
import 'current_user.dart';
import 'identity_controller.dart';
@@ -19,24 +27,26 @@ class AccountScreen extends ConsumerWidget {
if (next case AsyncError(:final error)) _showError(context, error);
});
- return Scaffold(
- appBar: AppBar(
- title: const Text('Account'),
+ return LedgerScreen(
+ onRefresh: () async => ref.invalidate(currentUserProvider),
+ band: NavBand(
+ title: 'Account',
+ leading: BandButton(
+ key: const Key('account-back'),
+ icon: Glyph.caretLeft,
+ onPressed: () => Navigator.of(context).pop(),
+ ),
actions: [
- TextButton(
- onPressed: () => ref.read(authControllerProvider.notifier).signOut(),
- child: const Text('Sign out'),
- ),
+ BandButton(label: 'Sign out', onPressed: () => ref.read(authControllerProvider.notifier).signOut()),
],
),
- body: RefreshIndicator(
- onRefresh: () async => ref.invalidate(currentUserProvider),
- child: switch (user) {
- AsyncData(value: final user) => _Identities(user: user),
- AsyncError(:final error) => _Message('$error'),
- _ => const Center(child: CircularProgressIndicator()),
- },
- ),
+ slivers: switch (user) {
+ AsyncData(value: final user) => [SliverToBoxAdapter(child: _Identities(user: user))],
+ AsyncError(:final error) => [_Message('$error')],
+ _ => const [
+ SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())),
+ ],
+ },
);
}
@@ -54,58 +64,91 @@ class _Identities extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
final linked = {for (final identity in user.identities) identity.provider.name: identity};
final busy = ref.watch(identityControllerProvider).isLoading;
- return ListView(
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- const _SectionHeader('Ways to sign in'),
+ const SectionRule('Ways to sign in'),
for (final provider in AuthProvider.values)
switch (linked[provider.name]) {
- final Identity identity => ListTile(
- title: Text(provider.name),
- subtitle: const Text('Linked', style: TextStyle(color: SpendableColors.positive)),
- trailing: TextButton(
+ final Identity identity => _Identity(
+ provider: provider,
+ status: 'Linked',
+ statusColor: colors.positive,
+ action: BandButton(
// Removing the last one is refused by the server, which answers 409.
key: Key('unlink-${provider.name}'),
+ label: 'Remove',
onPressed: busy
? null
: () => ref.read(identityControllerProvider.notifier).unlink(identity.id),
- child: const Text('Remove'),
),
),
- null => ListTile(
- title: Text(provider.name),
- subtitle: const Text('Not linked', style: TextStyle(color: SpendableColors.muted)),
- trailing: TextButton(
+ null => _Identity(
+ provider: provider,
+ status: 'Not linked',
+ statusColor: colors.tertiary,
+ action: BandButton(
key: Key('link-${provider.name}'),
+ label: 'Add',
onPressed: busy ? null : () => ref.read(identityControllerProvider.notifier).link(provider),
- child: const Text('Add'),
),
),
},
- const _SectionHeader('Banks'),
- ListTile(
- title: const Text('Connections allowed'),
- trailing: Text('${user.bankLimit}', style: const TextStyle(color: SpendableColors.muted)),
+ const SectionRule('Banks'),
+ LedgerRow(
+ child: Row(
+ children: [
+ Expanded(
+ child: Text('Connections allowed', style: SpendableType.body.copyWith(color: colors.primary)),
+ ),
+ Text('${user.bankLimit}', style: SpendableType.moneyInline.copyWith(color: colors.secondary)),
+ ],
+ ),
),
],
);
}
}
-class _SectionHeader extends StatelessWidget {
- const _SectionHeader(this.title);
+class _Identity extends StatelessWidget {
+ const _Identity({
+ required this.provider,
+ required this.status,
+ required this.statusColor,
+ required this.action,
+ });
+
+ final AuthProvider provider;
+ final String status;
+ final Color statusColor;
+ final Widget action;
- final String title;
+ /// The enum is the wire name; the screen says it the way a person would.
+ String _name(AuthProvider provider) => provider.name[0].toUpperCase() + provider.name.substring(1);
@override
Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
- child: Text(
- title.toUpperCase(),
- style: const TextStyle(color: SpendableColors.muted, fontSize: 12, letterSpacing: 0.8),
+ final colors = SpendableColors.of(context);
+
+ return LedgerRow(
+ padding: const EdgeInsets.only(left: SpendableSpace.gutter, right: SpendableSpace.hair),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(_name(provider), style: SpendableType.title.copyWith(color: colors.primary)),
+ Caption(status, color: statusColor),
+ ],
+ ),
+ ),
+ action,
+ ],
),
);
}
@@ -118,13 +161,18 @@ class _Message extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(32),
- child: Text(text, textAlign: TextAlign.center),
+ return SliverFillRemaining(
+ hasScrollBody: false,
+ child: Padding(
+ padding: const EdgeInsets.all(SpendableSpace.block),
+ child: Center(
+ child: Text(
+ text,
+ textAlign: TextAlign.center,
+ style: SpendableType.body.copyWith(color: SpendableColors.of(context).secondary),
+ ),
),
- ],
+ ),
);
}
}
diff --git a/mobile/lib/auth/sign_in_screen.dart b/mobile/lib/auth/sign_in_screen.dart
index 1e1e6f8e..b17b42b1 100644
--- a/mobile/lib/auth/sign_in_screen.dart
+++ b/mobile/lib/auth/sign_in_screen.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../theme.dart';
+import '../design/primary_button.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import 'auth_controller.dart';
import 'identity_tokens.dart';
@@ -10,40 +12,49 @@ class SignInScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
final auth = ref.watch(authControllerProvider);
return Scaffold(
+ backgroundColor: colors.ground,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 32),
+ padding: const EdgeInsets.symmetric(horizontal: SpendableSpace.block),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Text('Spendable', style: Theme.of(context).textTheme.displaySmall),
- const SizedBox(height: 8),
- const Text('Know what is left to spend.', style: TextStyle(color: SpendableColors.muted)),
+ Text('Spendable', style: SpendableType.moneyHero.copyWith(color: colors.primary)),
+ const SizedBox(height: SpendableSpace.hair),
+ Text(
+ 'Know what is left to spend.',
+ style: SpendableType.body.copyWith(color: colors.secondary),
+ ),
const SizedBox(height: 48),
for (final provider in AuthProvider.values) ...[
- _ProviderButton(
- provider: provider,
- enabled: !auth.isLoading,
- onPressed: () => ref.read(authControllerProvider.notifier).signIn(provider),
+ PrimaryButton(
+ key: Key('sign-in-${provider.name}'),
+ label: provider.label,
+ // The second way in is offered, not urged, so only the first is filled.
+ variant: provider == AuthProvider.values.first
+ ? ButtonVariant.filled
+ : ButtonVariant.plain,
+ onPressed: auth.isLoading
+ ? null
+ : () => ref.read(authControllerProvider.notifier).signIn(provider),
),
- const SizedBox(height: 12),
+ const SizedBox(height: SpendableSpace.step),
],
- if (auth.hasError) ...[
- const SizedBox(height: 12),
+ if (auth.hasError)
Text(
'${auth.error}',
key: const Key('sign-in-error'),
textAlign: TextAlign.center,
- style: const TextStyle(color: SpendableColors.negative),
+ style: SpendableType.subhead.copyWith(color: colors.negative),
),
- ],
],
),
),
@@ -53,25 +64,3 @@ class SignInScreen extends ConsumerWidget {
);
}
}
-
-class _ProviderButton extends StatelessWidget {
- const _ProviderButton({required this.provider, required this.enabled, required this.onPressed});
-
- final AuthProvider provider;
- final bool enabled;
- final VoidCallback onPressed;
-
- @override
- Widget build(BuildContext context) {
- return FilledButton(
- key: Key('sign-in-${provider.name}'),
- onPressed: enabled ? onPressed : null,
- style: FilledButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 16),
- backgroundColor: SpendableColors.surface,
- foregroundColor: Colors.white,
- ),
- child: Text(provider.label),
- );
- }
-}
diff --git a/mobile/lib/banks/banks_screen.dart b/mobile/lib/banks/banks_screen.dart
index f0b9ae4c..d8a3d2bb 100644
--- a/mobile/lib/banks/banks_screen.dart
+++ b/mobile/lib/banks/banks_screen.dart
@@ -2,10 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
+import '../budgets/budget_picker.dart';
import '../budgets/budgets_providers.dart';
+import '../design/band_button.dart';
+import '../design/caption.dart';
+import '../design/glyph_icon.dart';
+import '../design/ledger_row.dart';
+import '../design/ledger_screen.dart';
+import '../design/money_text.dart';
+import '../design/nav_band.dart';
+import '../design/picker_field.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import '../finance_kit/wallet_sync.dart';
import '../money.dart';
-import '../theme.dart';
import 'banks_controller.dart';
import 'banks_providers.dart';
@@ -25,65 +35,104 @@ class BanksScreen extends ConsumerWidget {
}
});
- return Scaffold(
- appBar: AppBar(
- title: const Text('Banks'),
+ return LedgerScreen(
+ onRefresh: () async => ref.invalidate(bankMembersProvider),
+ band: NavBand(
+ title: 'Banks',
actions: [
if (ref.watch(walletAvailableProvider).value ?? false)
- IconButton(
+ BandButton(
key: const Key('connect-apple'),
- tooltip: 'Connect Apple Card',
- icon: const Icon(Icons.wallet),
+ icon: Glyph.wallet,
onPressed: busy ? null : ref.read(banksControllerProvider.notifier).connectApple,
),
- IconButton(
+ BandButton(
key: const Key('connect-bank'),
- tooltip: 'Connect a bank',
- icon: const Icon(Icons.add),
+ icon: Glyph.plus,
onPressed: busy ? null : ref.read(banksControllerProvider.notifier).connect,
),
],
),
- body: RefreshIndicator(
- onRefresh: () async => ref.invalidate(bankMembersProvider),
- child: switch (members) {
- AsyncData(value: final members) when members.isEmpty => const _Message('No banks connected.'),
- AsyncData(value: final members) => ListView(
- children: [for (final member in members) _Member(member: member)],
+ slivers: switch (members) {
+ AsyncData(value: final members) when members.isEmpty => const [_Message('No banks connected.')],
+ AsyncData(value: final members) => [
+ SliverList.builder(
+ itemCount: members.length,
+ itemBuilder: (_, index) => _Member(member: members[index]),
),
- AsyncError(:final error) => _Message('$error'),
- _ => const Center(child: CircularProgressIndicator()),
- },
- ),
+ ],
+ AsyncError(:final error) => [_Message('$error')],
+ _ => const [
+ SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())),
+ ],
+ },
);
}
}
-class _Member extends ConsumerWidget {
+/// A bank and the accounts under it, which stay folded away until the bank is opened.
+class _Member extends ConsumerStatefulWidget {
const _Member({required this.member});
final BankMember member;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState<_Member> createState() => _MemberState();
+}
+
+class _MemberState extends ConsumerState<_Member> {
+ var _open = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
final controller = ref.read(banksControllerProvider.notifier);
+ final member = widget.member;
// Anything other than CONNECTED means Plaid needs the user to go back through Link.
final connected = member.status == 'CONNECTED';
- return ExpansionTile(
- key: Key('member-${member.id}'),
- leading: member.hasLogo ? _Logo(memberId: member.id) : const Icon(Icons.account_balance),
- title: Text(member.name),
- subtitle: connected ? null : const Text('Reconnect', style: TextStyle(color: SpendableColors.negative)),
- trailing: connected
- ? null
- : TextButton(
- key: Key('reconnect-${member.id}'),
- onPressed: () => controller.reconnect(member.id),
- child: const Text('Reconnect'),
- ),
- children: [for (final account in member.bankAccounts) _Account(account: account)],
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ LedgerRow(
+ key: Key('member-${member.id}'),
+ onTap: () => setState(() => _open = !_open),
+ child: Row(
+ children: [
+ SizedBox(
+ width: 32,
+ height: 32,
+ child: member.hasLogo
+ ? _Logo(memberId: member.id)
+ : GlyphIcon(Glyph.bank, size: 24, color: colors.secondary),
+ ),
+ const SizedBox(width: SpendableSpace.step),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(member.name, style: SpendableType.title.copyWith(color: colors.primary)),
+ if (!connected) Caption('Reconnect', color: colors.negative),
+ ],
+ ),
+ ),
+ if (!connected)
+ BandButton(
+ key: Key('reconnect-${member.id}'),
+ label: 'Reconnect',
+ onPressed: () => controller.reconnect(member.id),
+ ),
+ RotatedBox(
+ quarterTurns: _open ? 1 : 0,
+ child: GlyphIcon(Glyph.caretRight, size: 14, color: colors.tertiary),
+ ),
+ ],
+ ),
+ ),
+ if (_open)
+ for (final account in member.bankAccounts) _Account(account: account),
+ ],
);
}
}
@@ -95,14 +144,10 @@ class _Logo extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- return SizedBox(
- width: 32,
- height: 32,
- child: switch (ref.watch(bankLogoProvider(memberId))) {
- AsyncData(value: final bytes) => Image.memory(bytes, fit: BoxFit.contain),
- _ => const Icon(Icons.account_balance),
- },
- );
+ return switch (ref.watch(bankLogoProvider(memberId))) {
+ AsyncData(value: final bytes) => Image.memory(bytes, fit: BoxFit.contain),
+ _ => GlyphIcon(Glyph.bank, size: 24, color: SpendableColors.of(context).secondary),
+ };
}
}
@@ -113,14 +158,20 @@ class _Account extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
final controller = ref.read(banksControllerProvider.notifier);
final budgets = ref.watch(budgetOptionsProvider).value ?? const [];
- // An account that is not synced counts toward nothing, and reads that way.
- final color = account.sync_ ? Colors.white : SpendableColors.muted;
-
- return Padding(
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+ return LedgerRow(
+ // An account that is not synced counts toward nothing, and reads that way.
+ dimmed: !account.sync_,
+ ruleInset: SpendableSpace.block,
+ padding: const EdgeInsets.fromLTRB(
+ SpendableSpace.block,
+ SpendableSpace.tight,
+ SpendableSpace.gutter,
+ SpendableSpace.tight,
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -132,38 +183,31 @@ class _Account extends ConsumerWidget {
children: [
Text(
'${account.name} ••••${account.number ?? ''}',
- style: TextStyle(color: color, fontWeight: FontWeight.w600),
+ style: SpendableType.body.copyWith(color: colors.primary),
overflow: TextOverflow.ellipsis,
),
- Text(
- account.subType.toUpperCase(),
- style: const TextStyle(color: SpendableColors.muted, fontSize: 11),
- ),
+ Caption(account.subType),
],
),
),
- Text(formatCurrency(money(account.balance)), style: TextStyle(color: color)),
- Switch(
+ MoneyText(money(account.balance), style: SpendableType.moneyInline),
+ const SizedBox(width: SpendableSpace.tight),
+ Switch.adaptive(
key: Key('sync-account-${account.id}'),
value: account.sync_,
onChanged: (value) => controller.setSync(account, sync: value),
),
],
),
- DropdownButtonFormField(
+ PickerField(
key: Key('budget-for-${account.id}'),
- initialValue: account.budgetId,
- isExpanded: true,
- decoration: const InputDecoration(isDense: true),
- items: [
- const DropdownMenuItem(child: Text('Assign to budget')),
- for (final budget in budgets)
- DropdownMenuItem(
- value: budget.id,
- child: Text(budget.name, overflow: TextOverflow.ellipsis),
- ),
- ],
- onChanged: (value) => controller.assignBudget(account, value),
+ label: 'Assign to budget',
+ value: budgets.where((budget) => budget.id == account.budgetId).firstOrNull?.name,
+ onTap: () async {
+ final chosen = await pickBudget(context, ref);
+
+ if (chosen != null) await controller.assignBudget(account, chosen.id);
+ },
),
],
),
@@ -178,13 +222,18 @@ class _Message extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(32),
- child: Text(text, textAlign: TextAlign.center),
+ return SliverFillRemaining(
+ hasScrollBody: false,
+ child: Padding(
+ padding: const EdgeInsets.all(SpendableSpace.block),
+ child: Center(
+ child: Text(
+ text,
+ textAlign: TextAlign.center,
+ style: SpendableType.body.copyWith(color: SpendableColors.of(context).secondary),
+ ),
),
- ],
+ ),
);
}
}
diff --git a/mobile/lib/budgets/budget_form.dart b/mobile/lib/budgets/budget_form.dart
index 47bf24d6..b89d4736 100644
--- a/mobile/lib/budgets/budget_form.dart
+++ b/mobile/lib/budgets/budget_form.dart
@@ -1,14 +1,19 @@
+import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
import '../api/api_error.dart';
+import '../design/primary_button.dart';
+import '../design/sheet_header.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import 'budgets_controller.dart';
const _types = {
BudgetRequestTypeEnum.envelope: 'Envelope',
BudgetRequestTypeEnum.goal: 'Goal',
- BudgetRequestTypeEnum.tracking: 'Track spending only',
+ BudgetRequestTypeEnum.tracking: 'Tracking',
};
/// Editing one budget. `balance` is what the user wants allocated; the server diffs it into an
@@ -43,6 +48,7 @@ class _BudgetFormState extends ConsumerState {
@override
Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
final state = ref.watch(budgetsControllerProvider);
final errors = state.error is ApiError
? (state.error! as ApiError).fieldErrors
@@ -51,68 +57,72 @@ class _BudgetFormState extends ConsumerState {
return Padding(
padding: EdgeInsets.only(
- left: 24,
- right: 24,
- top: 24,
- bottom: MediaQuery.of(context).viewInsets.bottom + 24,
+ left: SpendableSpace.gutter,
+ right: SpendableSpace.gutter,
+ top: SpendableSpace.step,
+ bottom: MediaQuery.viewInsetsOf(context).bottom + SpendableSpace.block,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Text(
- widget.budget == null ? 'New budget' : 'Edit budget',
- style: Theme.of(context).textTheme.titleLarge,
- ),
- const SizedBox(height: 16),
+ SheetHeader(title: widget.budget == null ? 'New budget' : 'Edit budget'),
+ const SizedBox(height: SpendableSpace.step),
TextField(
key: const Key('budget-name'),
controller: _name,
+ style: SpendableType.body.copyWith(color: colors.primary),
decoration: InputDecoration(labelText: 'Name', errorText: errors['/name']),
),
- const SizedBox(height: 16),
- DropdownButtonFormField(
+ const SizedBox(height: SpendableSpace.gutter),
+ CupertinoSlidingSegmentedControl(
key: const Key('budget-type'),
- initialValue: _type,
- decoration: const InputDecoration(labelText: 'Budget type'),
- items: [
+ groupValue: _type,
+ backgroundColor: colors.separator,
+ thumbColor: colors.ground,
+ children: {
for (final entry in _types.entries)
- DropdownMenuItem(value: entry.key, child: Text(entry.value)),
- ],
- onChanged: (value) => setState(() => _type = value ?? _type),
+ entry.key: Padding(
+ padding: const EdgeInsets.symmetric(vertical: SpendableSpace.tight),
+ child: Text(entry.value, style: SpendableType.body.copyWith(color: colors.primary)),
+ ),
+ },
+ onValueChanged: (value) => setState(() => _type = value ?? _type),
),
if (!tracking) ...[
- const SizedBox(height: 16),
+ const SizedBox(height: SpendableSpace.tight),
TextField(
key: const Key('budget-amount'),
controller: _budgetedAmount,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ style: SpendableType.moneyInline.copyWith(color: colors.primary),
decoration: InputDecoration(
labelText: _type == BudgetRequestTypeEnum.goal ? 'Goal amount' : 'Budgeted amount',
errorText: errors['/budgeted_amount'],
),
),
- const SizedBox(height: 16),
+ const SizedBox(height: SpendableSpace.gutter),
TextField(
key: const Key('budget-balance'),
controller: _balance,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
+ style: SpendableType.moneyInline.copyWith(color: colors.primary),
decoration: InputDecoration(labelText: 'Allocated', errorText: errors['/balance']),
),
],
- const SizedBox(height: 24),
- FilledButton(
+ const SizedBox(height: SpendableSpace.block),
+ PrimaryButton(
key: const Key('budget-save'),
+ label: 'Save',
onPressed: state.isLoading ? null : _save,
- child: const Text('Save'),
),
if (widget.budget case final budget?) ...[
- const SizedBox(height: 8),
- TextButton(
+ const SizedBox(height: SpendableSpace.tight),
+ PrimaryButton(
key: const Key('budget-archive'),
+ label: 'Archive budget',
+ variant: ButtonVariant.destructive,
onPressed: state.isLoading ? null : () => _archive(budget.id),
- style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
- child: const Text('Archive budget'),
),
],
],
diff --git a/mobile/lib/budgets/budget_picker.dart b/mobile/lib/budgets/budget_picker.dart
new file mode 100644
index 00000000..fd0d49a9
--- /dev/null
+++ b/mobile/lib/budgets/budget_picker.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spendable_api/spendable_api.dart';
+
+import '../design/glass_sheet.dart';
+import '../design/ledger_row.dart';
+import '../design/tokens.dart';
+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),
+ ),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart
index 08f700fa..94e53078 100644
--- a/mobile/lib/budgets/budgets_screen.dart
+++ b/mobile/lib/budgets/budgets_screen.dart
@@ -4,8 +4,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
import '../auth/account_screen.dart';
+import '../design/band_button.dart';
+import '../design/caption.dart';
+import '../design/glass_menu.dart';
+import '../design/glass_sheet.dart';
+import '../design/glyph_icon.dart';
+import '../design/ledger_row.dart';
+import '../design/ledger_screen.dart';
+import '../design/money_text.dart';
+import '../design/nav_band.dart';
+import '../design/section_rule.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import '../money.dart';
-import '../theme.dart';
import 'budget_card.dart';
import 'budget_form.dart';
import 'budgets_providers.dart';
@@ -34,43 +45,66 @@ class BudgetsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(budgetSummaryProvider);
- return Scaffold(
- appBar: AppBar(
+ return LedgerScreen(
+ onRefresh: () async => ref.invalidate(budgetSummaryProvider),
+ band: NavBand(
title: switch (summary) {
+ AsyncData(value: final summary) => monthName(summary.month),
+ _ => 'Budgets',
+ },
+ largeTitle: switch (summary) {
AsyncData(value: final summary) => _MonthPicker(summary: summary),
- _ => const Text('Budgets'),
+ _ => null,
},
actions: [
- IconButton(
+ BandButton(
key: const Key('open-account'),
- icon: const Icon(Icons.person_outline),
+ icon: Glyph.user,
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AccountScreen())),
),
- IconButton(
+ BandButton(
key: const Key('new-budget'),
- icon: const Icon(Icons.add),
+ icon: Glyph.plus,
onPressed: () => openBudgetForm(context),
),
],
),
- body: RefreshIndicator(
- onRefresh: () async => ref.invalidate(budgetSummaryProvider),
- child: switch (summary) {
- AsyncData(value: final summary) => _Budgets(summary: summary),
- AsyncError(:final error) => _Retry(message: '$error'),
- _ => const Center(child: CircularProgressIndicator()),
- },
- ),
+ slivers: switch (summary) {
+ AsyncData(value: final summary) => _budgets(summary),
+ AsyncError(:final error) => [_Retry(message: '$error')],
+ _ => const [
+ SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())),
+ ],
+ },
);
}
+
+ List _budgets(BudgetSummary summary) {
+ final listed = listedBudgets(summary);
+ final owned = listed.where((budget) => budget.id != creditCardsId).toList();
+ final creditCards = listed.where((budget) => budget.id == creditCardsId).firstOrNull;
+
+ return [
+ SliverToBoxAdapter(child: _Totals(summary: summary)),
+ SliverList.builder(
+ itemCount: owned.length,
+ itemBuilder: (_, index) => _Row(budget: owned[index], summary: summary),
+ ),
+ // Card debt is read off the connected accounts rather than allocated, so it is set apart from
+ // the budgets the user keeps.
+ if (creditCards case final creditCards?) ...[
+ const SliverToBoxAdapter(child: SectionRule('From connected accounts')),
+ SliverToBoxAdapter(
+ child: _Row(budget: creditCards, summary: summary),
+ ),
+ ],
+ ];
+ }
}
-Future openBudgetForm(BuildContext context, {Budget? budget}) => showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- builder: (_) => BudgetForm(budget: budget),
-);
+Future openBudgetForm(BuildContext context, {Budget? budget}) =>
+ showGlassSheet(context, (_) => BudgetForm(budget: budget));
class _MonthPicker extends ConsumerWidget {
const _MonthPicker({required this.summary});
@@ -79,64 +113,45 @@ class _MonthPicker extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- return PopupMenuButton(
+ final colors = SpendableColors.of(context);
+
+ return GestureDetector(
key: const Key('month-picker'),
- onSelected: (month) => ref.read(selectedMonthProvider.notifier).select(month),
- itemBuilder: (_) => [
- for (final entry in summary.spentByMonth)
- PopupMenuItem(
- value: entry.month,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(monthName(entry.month)),
- Text(
- 'spent: ${formatCurrency(money(entry.spent))}',
- style: const TextStyle(color: SpendableColors.muted, fontSize: 12),
- ),
- ],
- ),
- ),
- ],
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(monthName(summary.month), style: Theme.of(context).textTheme.titleLarge),
- const Icon(Icons.unfold_more, size: 18, color: SpendableColors.muted),
- ],
+ behavior: HitTestBehavior.opaque,
+ onTap: () => _pick(context, ref),
+ // A long month at a large size runs past a narrow phone, and the title is worth more whole
+ // and a little smaller than it is truncated.
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: Alignment.centerLeft,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(monthName(summary.month), style: SpendableType.largeTitle.copyWith(color: colors.primary)),
+ const SizedBox(width: SpendableSpace.tight),
+ GlyphIcon(Glyph.caretDown, size: 17, color: colors.tertiary),
+ ],
+ ),
),
);
}
-}
-
-class _Budgets extends StatelessWidget {
- const _Budgets({required this.summary});
- final BudgetSummary summary;
+ Future _pick(BuildContext context, WidgetRef ref) async {
+ final chosen = await showGlassMenu(context, [
+ for (final entry in summary.spentByMonth)
+ GlassMenuItem(
+ value: entry.month,
+ title: monthName(entry.month),
+ subtitle: 'spent: ${formatCurrency(money(entry.spent).abs())}',
+ selected: entry.month == summary.month,
+ ),
+ ]);
- @override
- Widget build(BuildContext context) {
- final budgets = listedBudgets(summary);
-
- return ListView.separated(
- padding: const EdgeInsets.only(bottom: 24),
- itemCount: budgets.length + 1,
- separatorBuilder: (_, _) => const SizedBox(height: 8),
- itemBuilder: (context, index) {
- if (index == 0) return _Totals(summary: summary);
-
- final budget = budgets[index - 1];
-
- return _Card(
- budget: budget,
- spent: money(summary.spent[budget.id] ?? '0').abs(),
- currentMonth: summary.currentMonth,
- );
- },
- );
+ if (chosen != null) ref.read(selectedMonthProvider.notifier).select(chosen);
}
}
+/// The answer the app exists to give, and the two figures it is worked out from.
class _Totals extends StatelessWidget {
const _Totals({required this.summary});
@@ -144,37 +159,45 @@ class _Totals extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final spendable = money(summary.spendable);
-
- return Container(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
- decoration: const BoxDecoration(
- border: Border(bottom: BorderSide(color: Colors.white10)),
+ final colors = SpendableColors.of(context);
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(
+ SpendableSpace.gutter,
+ SpendableSpace.tight,
+ SpendableSpace.gutter,
+ 0,
),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.end,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- if (summary.currentMonth)
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const _Caption('Spendable'),
- Text(
- formatCurrency(spendable),
- key: const Key('spendable-total'),
- style: TextStyle(
- fontSize: 34,
- fontWeight: FontWeight.w600,
- color: spendable.sign < 0 ? SpendableColors.negative : SpendableColors.positive,
- ),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ if (summary.currentMonth)
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Caption('Spendable'),
+ MoneyText(
+ money(summary.spendable),
+ key: const Key('spendable-total'),
+ style: SpendableType.moneyHero,
+ creditIsPositive: true,
+ ),
+ ],
),
- ],
- ),
- ),
- if (summary.currentMonth) _Total(label: 'Allocated', amount: money(summary.allocatedTotal)),
- const SizedBox(width: 24),
- _Total(label: 'Spent', amount: money(summary.spentTotal)),
+ ),
+ if (summary.currentMonth) ...[
+ _Total(label: 'Allocated', amount: money(summary.allocatedTotal)),
+ const SizedBox(width: SpendableSpace.gutter),
+ ],
+ _Total(label: 'Spent', amount: money(summary.spentTotal)),
+ ],
+ ),
+ const SizedBox(height: SpendableSpace.step),
+ Container(height: 1, color: colors.separator),
],
),
);
@@ -192,138 +215,111 @@ class _Total extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
- Text(formatCurrency(amount), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600)),
- _Caption(label),
+ Caption(label),
+ const SizedBox(height: 1),
+ MoneyText(amount, style: SpendableType.moneyInline),
],
);
}
}
-class _Caption extends StatelessWidget {
- const _Caption(this.text);
+class _Row extends StatelessWidget {
+ const _Row({required this.budget, required this.summary});
- final String text;
+ final Budget budget;
+ final BudgetSummary summary;
@override
Widget build(BuildContext context) {
- return Text(
- text.toUpperCase(),
- style: const TextStyle(color: SpendableColors.muted, fontSize: 11, letterSpacing: 0.8),
- );
- }
-}
-
-class _Card extends StatelessWidget {
- const _Card({required this.budget, required this.spent, required this.currentMonth});
+ final colors = SpendableColors.of(context);
- static const _barColors = {
- CardBar.under: SpendableColors.accent,
- CardBar.over: SpendableColors.negative,
- CardBar.goal: SpendableColors.positive,
- };
-
- final Budget budget;
- final Decimal spent;
- final bool currentMonth;
+ final card = BudgetCard.build(
+ budget: budget,
+ spent: money(summary.spent[budget.id] ?? '0').abs(),
+ currentMonth: summary.currentMonth,
+ );
- @override
- Widget build(BuildContext context) {
- final card = BudgetCard.build(budget: budget, spent: spent, currentMonth: currentMonth);
-
- return Card(
- margin: const EdgeInsets.symmetric(horizontal: 16),
- color: SpendableColors.surface,
- child: InkWell(
- // 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),
- borderRadius: BorderRadius.circular(12),
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ final barColors = {
+ CardBar.under: colors.accent,
+ CardBar.over: colors.negative,
+ CardBar.goal: colors.positive,
+ };
+
+ 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),
+ ruleInset: 0,
+ progress: card.percent == null ? null : card.percent! / 100,
+ progressColor: barColors[card.bar],
+ padding: const EdgeInsets.fromLTRB(
+ SpendableSpace.gutter,
+ SpendableSpace.step,
+ SpendableSpace.gutter,
+ SpendableSpace.step,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
children: [
- Row(
- children: [
- Expanded(
- child: Text(
- budget.name,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(fontWeight: FontWeight.w600),
+ Expanded(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ Flexible(
+ child: Text(
+ budget.name,
+ overflow: TextOverflow.ellipsis,
+ style: SpendableType.title.copyWith(color: colors.primary),
+ ),
),
- ),
- _Pill(type: budget.type),
- ],
- ),
- const SizedBox(height: 12),
- Row(
- crossAxisAlignment: CrossAxisAlignment.baseline,
- textBaseline: TextBaseline.alphabetic,
- children: [
- Text(
- formatCurrency(card.amount),
- key: Key('amount-${budget.id}'),
- style: TextStyle(
- fontSize: 28,
- fontWeight: FontWeight.w600,
- color: card.amount.sign < 0 ? SpendableColors.negative : Colors.white,
- ),
- ),
- const SizedBox(width: 8),
- _Caption(card.label),
- ],
- ),
- if (card.percent case final percent?) ...[
- const SizedBox(height: 12),
- ClipRRect(
- borderRadius: BorderRadius.circular(2),
- child: LinearProgressIndicator(
- key: Key('bar-${budget.id}'),
- value: percent / 100,
- minHeight: 4,
- backgroundColor: Colors.white10,
- valueColor: AlwaysStoppedAnimation(_barColors[card.bar]),
- ),
+ const SizedBox(width: SpendableSpace.tight),
+ _Kind(type: budget.type),
+ ],
),
- ],
- if (card.footer case final footer?) ...[
- const SizedBox(height: 10),
- Text(footer, style: const TextStyle(color: SpendableColors.muted, fontSize: 12)),
- ],
+ ),
+ MoneyText(card.amount, key: Key('amount-${budget.id}'), style: SpendableType.moneyRow),
+ const SizedBox(width: SpendableSpace.hair),
+ Caption(card.label),
],
),
- ),
+ if (card.footer case final footer?) ...[
+ const SizedBox(height: 1),
+ Text(footer, style: SpendableType.subhead.copyWith(color: colors.secondary)),
+ ],
+ ],
),
);
}
}
-class _Pill extends StatelessWidget {
- const _Pill({required this.type});
+/// The budget's kind, said in the margin next to its name rather than badged.
+class _Kind extends StatelessWidget {
+ const _Kind({required this.type});
static const _labels = {
- BudgetTypeEnum.envelope: ('Envelope', SpendableColors.accent),
- BudgetTypeEnum.goal: ('Goal', SpendableColors.positive),
- BudgetTypeEnum.tracking: ('Tracking', SpendableColors.muted),
+ BudgetTypeEnum.envelope: 'Envelope',
+ BudgetTypeEnum.goal: 'Goal',
+ BudgetTypeEnum.tracking: 'Tracking',
};
final BudgetTypeEnum type;
@override
Widget build(BuildContext context) {
- final (label, color) = _labels[type] ?? ('Envelope', SpendableColors.accent);
-
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
- decoration: BoxDecoration(
- color: color.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(999),
- border: Border.all(color: color.withValues(alpha: 0.2)),
- ),
- child: Text(
- label,
- style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w500),
- ),
- );
+ final colors = SpendableColors.of(context);
+
+ final color = switch (type) {
+ BudgetTypeEnum.goal => colors.positive,
+ BudgetTypeEnum.tracking => colors.trackingPill,
+ _ => colors.accent,
+ };
+
+ return Caption(_labels[type] ?? 'Envelope', color: color);
}
}
@@ -334,22 +330,23 @@ class _Retry extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- return ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(32),
- child: Column(
- children: [
- Text(message, textAlign: TextAlign.center),
- const SizedBox(height: 16),
- FilledButton(
- onPressed: () => ref.invalidate(budgetSummaryProvider),
- child: const Text('Try again'),
- ),
- ],
- ),
+ final colors = SpendableColors.of(context);
+
+ return SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.all(SpendableSpace.block),
+ child: Column(
+ children: [
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: SpendableType.body.copyWith(color: colors.secondary),
+ ),
+ const SizedBox(height: SpendableSpace.gutter),
+ BandButton(label: 'Try again', onPressed: () => ref.invalidate(budgetSummaryProvider)),
+ ],
),
- ],
+ ),
);
}
}
diff --git a/mobile/lib/design/band_button.dart b/mobile/lib/design/band_button.dart
new file mode 100644
index 00000000..14ece6bd
--- /dev/null
+++ b/mobile/lib/design/band_button.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'glyph_icon.dart';
+import 'tokens.dart';
+import 'typography.dart';
+
+/// An action in the nav band: an icon or a word, in accent, over a 44pt target and nothing else.
+class BandButton extends StatelessWidget {
+ const BandButton({super.key, this.icon, this.label, required this.onPressed})
+ : assert(icon != null || label != null, 'a band button needs an icon or a label');
+
+ final Glyph? icon;
+ final String? label;
+ final VoidCallback? onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+ final color = onPressed == null ? colors.tertiary : colors.accent;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onPressed == null
+ ? null
+ : () {
+ HapticFeedback.lightImpact();
+ onPressed!();
+ },
+ child: Container(
+ constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
+ padding: EdgeInsets.symmetric(horizontal: label == null ? 0 : SpendableSpace.tight),
+ alignment: Alignment.center,
+ child: icon != null
+ ? GlyphIcon(icon!, color: color)
+ : Text(label!, style: SpendableType.title.copyWith(color: color)),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/caption.dart b/mobile/lib/design/caption.dart
new file mode 100644
index 00000000..d532d332
--- /dev/null
+++ b/mobile/lib/design/caption.dart
@@ -0,0 +1,20 @@
+import 'package:flutter/material.dart';
+
+import 'tokens.dart';
+import 'typography.dart';
+
+/// The small uppercase label that names a figure without competing with it.
+class Caption extends StatelessWidget {
+ const Caption(this.text, {super.key, this.color});
+
+ final String text;
+ final Color? color;
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(
+ text.toUpperCase(),
+ style: SpendableType.caption.copyWith(color: color ?? SpendableColors.of(context).tertiary),
+ );
+ }
+}
diff --git a/mobile/lib/design/glass.dart b/mobile/lib/design/glass.dart
new file mode 100644
index 00000000..65929a96
--- /dev/null
+++ b/mobile/lib/design/glass.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+
+import 'tokens.dart';
+
+/// A pane of glass: what is behind it blurred and saturated, a tint over that, a lit top edge and a
+/// hairline ring around it. Every control in the app is cut from one of these; content never is.
+class GlassPanel extends StatelessWidget {
+ const GlassPanel({
+ super.key,
+ required this.child,
+ required this.blur,
+ required this.tint,
+ this.borderRadius = BorderRadius.zero,
+ this.shadow = false,
+ this.litEdge = true,
+ });
+
+ final Widget child;
+ final double blur;
+ final Color tint;
+ final BorderRadius borderRadius;
+
+ /// Only glass that floats free of an edge casts one.
+ final bool shadow;
+ final bool litEdge;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: borderRadius,
+ boxShadow: shadow
+ ? [BoxShadow(color: colors.chromeShadow, blurRadius: 34, offset: const Offset(0, 14))]
+ : null,
+ ),
+ child: ClipRRect(
+ borderRadius: borderRadius,
+ // Grouped so the several panes on a screen sample the backdrop once between them, and
+ // bounded so the kernel only reads what is behind the pane. An unbounded blur pulls in
+ // transparent black from outside its edges, which is what makes a plain BackdropFilter
+ // read as a washed-out rectangle rather than as glass.
+ child: BackdropFilter.grouped(
+ filterConfig: ImageFilterConfig.compose(
+ outer: const ImageFilterConfig(ColorFilter.matrix(_saturate)),
+ inner: ImageFilterConfig.blur(sigmaX: blur, sigmaY: blur, bounded: true),
+ ),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: tint,
+ borderRadius: borderRadius,
+ border: Border.all(color: colors.ring, width: 0.5),
+ ),
+ child: Stack(
+ children: [
+ child,
+ // The highlight along the top, which is what makes the pane read as lit rather than
+ // as a flat translucent rectangle. Flutter has no inset shadow.
+ //
+ // It fades out towards the ends rather than running the full width: glass catches
+ // the light across its crown, and an even line reads as a drawn border instead.
+ if (litEdge)
+ Positioned(
+ top: 0,
+ left: borderRadius.topLeft.x,
+ right: borderRadius.topRight.x,
+ height: 1,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Colors.transparent, colors.litEdge, Colors.transparent],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// saturate(180%), in the matrix form `ColorFilter` takes. The luminance weights are the ones the
+/// CSS filter is defined with.
+const _saturate = [
+ 1.6296, -0.5720, -0.0576, 0, 0, //
+ -0.1704, 1.2280, -0.0576, 0, 0,
+ -0.1704, -0.5720, 1.7424, 0, 0,
+ 0, 0, 0, 1, 0,
+];
diff --git a/mobile/lib/design/glass_menu.dart b/mobile/lib/design/glass_menu.dart
new file mode 100644
index 00000000..8acd08e0
--- /dev/null
+++ b/mobile/lib/design/glass_menu.dart
@@ -0,0 +1,129 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+
+import 'glass.dart';
+import 'ledger_row.dart';
+import 'tokens.dart';
+import 'typography.dart';
+
+/// One choice in a [showGlassMenu].
+class GlassMenuItem {
+ const GlassMenuItem({required this.value, required this.title, this.subtitle, this.selected = false});
+
+ final T value;
+ final String title;
+ final String? subtitle;
+ final bool selected;
+}
+
+/// A menu hung under whatever was pressed. The screen behind it blurs rather than dims flat, so the
+/// menu reads as the near layer of the same glass the chrome is made of.
+Future showGlassMenu(BuildContext context, List> items) {
+ final anchor = context.findRenderObject()! as RenderBox;
+ final overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
+ final topLeft = anchor.localToGlobal(Offset.zero, ancestor: overlay);
+
+ return Navigator.of(context).push(
+ _GlassMenuRoute(items: items, anchor: topLeft + Offset(0, anchor.size.height + SpendableSpace.tight)),
+ );
+}
+
+/// Tall enough for a year of months, short enough that it never reads as a screen.
+const _maxHeight = 420.0;
+
+class _GlassMenuRoute extends PopupRoute {
+ _GlassMenuRoute({required this.items, required this.anchor});
+
+ final List> items;
+ final Offset anchor;
+
+ @override
+ Color? get barrierColor => null;
+
+ @override
+ bool get barrierDismissible => true;
+
+ @override
+ String get barrierLabel => 'Dismiss';
+
+ @override
+ Duration get transitionDuration => const Duration(milliseconds: 220);
+
+ @override
+ Widget buildPage(BuildContext context, Animation animation, Animation secondary) {
+ 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)),
+ ),
+ ),
+ ),
+ 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: ScaleTransition(
+ scale: Tween(begin: 0.94, end: 1.0).animate(eased),
+ alignment: Alignment.topLeft,
+ // A route of its own has no Material above it, and text without one falls back to the
+ // framework's yellow-underlined error style.
+ child: Material(
+ type: MaterialType.transparency,
+ child: GlassPanel(
+ blur: SpendableChrome.menuBlur,
+ tint: colors.menu,
+ borderRadius: BorderRadius.circular(SpendableRadius.menu),
+ shadow: true,
+ child: ListView.builder(
+ padding: EdgeInsets.zero,
+ physics: const BouncingScrollPhysics(),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ final item = items[index];
+
+ return LedgerRow(
+ selected: item.selected,
+ ruleInset: SpendableSpace.step,
+ padding: const EdgeInsets.symmetric(
+ horizontal: SpendableSpace.step,
+ vertical: SpendableSpace.tight + 1,
+ ),
+ onTap: () => Navigator.of(context).pop(item.value),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(item.title, style: SpendableType.title.copyWith(color: colors.primary)),
+ if (item.subtitle case final subtitle?)
+ Text(subtitle, style: SpendableType.subhead.copyWith(color: colors.secondary)),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/glass_sheet.dart b/mobile/lib/design/glass_sheet.dart
new file mode 100644
index 00000000..4d84dd47
--- /dev/null
+++ b/mobile/lib/design/glass_sheet.dart
@@ -0,0 +1,51 @@
+import 'package:flutter/material.dart';
+
+import 'glass.dart';
+import 'tokens.dart';
+
+/// Every sheet in the app: glass over a dimmed screen, a grabber, and nothing between it and the
+/// keyboard.
+Future showGlassSheet(BuildContext context, WidgetBuilder builder) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ barrierColor: const Color(0x59000000),
+ builder: (context) => _GlassSheet(child: builder(context)),
+ );
+}
+
+class _GlassSheet extends StatelessWidget {
+ const _GlassSheet({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return GlassPanel(
+ blur: SpendableChrome.menuBlur,
+ tint: colors.menu,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(SpendableRadius.menu)),
+ shadow: true,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: SpendableSpace.tight, bottom: SpendableSpace.hair),
+ child: Container(
+ width: 36,
+ height: 5,
+ decoration: BoxDecoration(
+ color: colors.tertiary.withValues(alpha: 0.6),
+ borderRadius: BorderRadius.circular(2.5),
+ ),
+ ),
+ ),
+ Flexible(child: child),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/glass_tab_bar.dart b/mobile/lib/design/glass_tab_bar.dart
new file mode 100644
index 00000000..c96796f2
--- /dev/null
+++ b/mobile/lib/design/glass_tab_bar.dart
@@ -0,0 +1,134 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'glass.dart';
+import 'glyph_icon.dart';
+import 'tokens.dart';
+import 'typography.dart';
+
+/// How far the selection capsule sits inside its slot.
+const _capsuleInset = 6.0;
+
+/// One destination in [GlassTabBar]. The selected tab fills its glyph, which is what iOS does to
+/// say a tab is the one you are in without relying on colour alone.
+class TabDestination {
+ const TabDestination({required this.key, required this.icon, required this.fill, required this.label});
+
+ final Key key;
+ final Glyph icon;
+ final Glyph fill;
+ final String label;
+}
+
+/// The tab bar floats clear of the bottom edge as a glass capsule, and the list scrolls under it
+/// rather than stopping above it.
+class GlassTabBar extends StatelessWidget {
+ const GlassTabBar({
+ super.key,
+ required this.destinations,
+ required this.selectedIndex,
+ required this.onSelected,
+ });
+
+ final List destinations;
+ final int selectedIndex;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return Padding(
+ padding: const EdgeInsets.only(
+ left: SpendableChrome.inset,
+ right: SpendableChrome.inset,
+ bottom: SpendableChrome.bottomInset,
+ ),
+ child: GlassPanel(
+ blur: SpendableChrome.tabBarBlur,
+ tint: colors.chrome,
+ borderRadius: BorderRadius.circular(SpendableRadius.tabBar),
+ shadow: true,
+ child: SizedBox(
+ height: SpendableChrome.tabBarHeight,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final slot = constraints.maxWidth / destinations.length;
+
+ return Stack(
+ children: [
+ // The capsule slides rather than reappearing, so the eye follows the selection
+ // across instead of having to find it again.
+ AnimatedPositioned(
+ duration: const Duration(milliseconds: 260),
+ curve: Curves.easeOutCubic,
+ left: slot * selectedIndex + _capsuleInset,
+ width: slot - _capsuleInset * 2,
+ top: SpendableSpace.tight,
+ bottom: SpendableSpace.tight,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: colors.accent.withValues(alpha: 0.22),
+ borderRadius: BorderRadius.circular(SpendableRadius.capsule),
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ for (final (index, destination) in destinations.indexed)
+ Expanded(
+ child: _Tab(
+ destination: destination,
+ selected: index == selectedIndex,
+ onTap: () {
+ HapticFeedback.selectionClick();
+ onSelected(index);
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _Tab extends StatelessWidget {
+ const _Tab({required this.destination, required this.selected, required this.onTap});
+
+ final TabDestination destination;
+ final bool selected;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+ final color = selected ? colors.accent : colors.secondary;
+
+ return GestureDetector(
+ key: destination.key,
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ GlyphIcon(selected ? destination.fill : destination.icon, size: 23, color: color),
+ Text(
+ destination.label,
+ style: SpendableType.caption.copyWith(
+ fontSize: 10,
+ letterSpacing: 0,
+ color: color,
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/glyph_icon.dart b/mobile/lib/design/glyph_icon.dart
new file mode 100644
index 00000000..6c28dd72
--- /dev/null
+++ b/mobile/lib/design/glyph_icon.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+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 {
+ bank,
+ bankFill,
+ caretDown,
+ caretLeft,
+ caretRight,
+ checkCircleFill,
+ circle,
+ copy,
+ copyFill,
+ creditCard,
+ creditCardFill,
+ funnel,
+ minusCircle,
+ money,
+ moneyFill,
+ plus,
+ trash,
+ user,
+ wallet,
+ x;
+
+ String get asset =>
+ 'assets/icons/${name.replaceAllMapped(RegExp('[A-Z]'), (match) => '-${match[0]!.toLowerCase()}')}.svg';
+}
+
+class GlyphIcon extends StatelessWidget {
+ const GlyphIcon(this.glyph, {super.key, this.size = 22, this.color});
+
+ final Glyph glyph;
+ final double size;
+ final Color? color;
+
+ @override
+ Widget build(BuildContext context) {
+ return SvgPicture.asset(
+ glyph.asset,
+ width: size,
+ height: size,
+ colorFilter: ColorFilter.mode(color ?? SpendableColors.of(context).primary, BlendMode.srcIn),
+ );
+ }
+}
diff --git a/mobile/lib/design/ledger_row.dart b/mobile/lib/design/ledger_row.dart
new file mode 100644
index 00000000..8f7d5001
--- /dev/null
+++ b/mobile/lib/design/ledger_row.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+
+import 'tokens.dart';
+
+/// A row in the ledger. It has no card, no corner and no fill of its own - what separates it from
+/// the next row is the hairline it closes with, and progress is that same hairline thickened to 2px
+/// and coloured for as far as it has got.
+class LedgerRow extends StatefulWidget {
+ const LedgerRow({
+ super.key,
+ required this.child,
+ this.onTap,
+ this.onLongPress,
+ this.selected = false,
+ this.dimmed = false,
+ this.progress,
+ this.progressColor,
+ this.ruleInset = SpendableSpace.gutter,
+ this.padding = const EdgeInsets.symmetric(
+ horizontal: SpendableSpace.gutter,
+ vertical: SpendableSpace.step,
+ ),
+ });
+
+ final Widget child;
+ final VoidCallback? onTap;
+ final VoidCallback? onLongPress;
+ final bool selected;
+
+ /// An excluded transaction, or one already paired as a transfer: still there, out of the running.
+ final bool dimmed;
+
+ /// 0 to 1 of the row's full width. Null leaves the plain hairline.
+ final double? progress;
+ final Color? progressColor;
+
+ /// The rule runs full bleed under a budget and inset under a list row, matching where the row's
+ /// own content starts.
+ final double ruleInset;
+ final EdgeInsets padding;
+
+ @override
+ State createState() => _LedgerRowState();
+}
+
+class _LedgerRowState extends State {
+ var _pressed = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+ final tappable = widget.onTap != null || widget.onLongPress != null;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: widget.onTap,
+ onLongPress: widget.onLongPress,
+ onTapDown: tappable ? (_) => setState(() => _pressed = true) : null,
+ onTapUp: tappable ? (_) => setState(() => _pressed = false) : null,
+ onTapCancel: tappable ? () => setState(() => _pressed = false) : null,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 120),
+ color: switch ((widget.selected, _pressed)) {
+ (true, _) => colors.accent.withValues(alpha: 0.12),
+ (_, true) => colors.primary.withValues(alpha: 0.05),
+ _ => Colors.transparent,
+ },
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: widget.padding,
+ child: Opacity(opacity: widget.dimmed ? 0.4 : 1, child: widget.child),
+ ),
+ _Rule(inset: widget.ruleInset, progress: widget.progress, color: widget.progressColor),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _Rule extends StatelessWidget {
+ const _Rule({required this.inset, required this.progress, required this.color});
+
+ final double inset;
+ final double? progress;
+ final Color? color;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return SizedBox(
+ height: 2,
+ child: Stack(
+ children: [
+ Positioned(
+ left: inset,
+ right: 0,
+ bottom: 0,
+ height: 1,
+ child: ColoredBox(color: colors.separator),
+ ),
+ if (progress case final progress?)
+ Positioned.fill(
+ child: Align(
+ alignment: Alignment.bottomLeft,
+ child: FractionallySizedBox(
+ widthFactor: progress.clamp(0, 1),
+ heightFactor: 1,
+ child: ColoredBox(color: color ?? colors.accent),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/ledger_screen.dart b/mobile/lib/design/ledger_screen.dart
new file mode 100644
index 00000000..5b1d4c44
--- /dev/null
+++ b/mobile/lib/design/ledger_screen.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+import 'nav_band.dart';
+import 'tokens.dart';
+
+/// The shape every screen takes: a band at the top, a bouncing list under it, and whatever the
+/// screen needs pinned to the bottom floating over the last of it.
+class LedgerScreen extends StatelessWidget {
+ const LedgerScreen({super.key, required this.band, required this.slivers, this.onRefresh, this.bottomBar});
+
+ final NavBand band;
+ final List slivers;
+ final Future Function()? onRefresh;
+
+ /// Floats over the list, above the tab bar. The list already clears both.
+ final Widget? bottomBar;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return Scaffold(
+ backgroundColor: colors.ground,
+ body: Stack(
+ children: [
+ CustomScrollView(
+ physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
+ slivers: [
+ band,
+ if (onRefresh case final onRefresh?) CupertinoSliverRefreshControl(onRefresh: onRefresh),
+ ...slivers,
+ SliverToBoxAdapter(
+ child: SizedBox(height: MediaQuery.paddingOf(context).bottom + SpendableSpace.block),
+ ),
+ ],
+ ),
+ if (bottomBar case final bottomBar?)
+ Positioned(left: 0, right: 0, bottom: MediaQuery.paddingOf(context).bottom, child: bottomBar),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/money_text.dart b/mobile/lib/design/money_text.dart
new file mode 100644
index 00000000..6a0fb7ec
--- /dev/null
+++ b/mobile/lib/design/money_text.dart
@@ -0,0 +1,27 @@
+import 'package:decimal/decimal.dart';
+import 'package:flutter/material.dart';
+
+import '../money.dart';
+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});
+
+ final Decimal amount;
+ final TextStyle style;
+ final bool creditIsPositive;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ final color = switch (amount.sign) {
+ < 0 => colors.negative,
+ _ => creditIsPositive ? colors.positive : colors.primary,
+ };
+
+ return Text(formatCurrency(amount), style: style.copyWith(color: color));
+ }
+}
diff --git a/mobile/lib/design/nav_band.dart b/mobile/lib/design/nav_band.dart
new file mode 100644
index 00000000..1e66626a
--- /dev/null
+++ b/mobile/lib/design/nav_band.dart
@@ -0,0 +1,135 @@
+import 'package:flutter/material.dart';
+
+import 'glass.dart';
+import 'tokens.dart';
+import 'typography.dart';
+
+/// The band across the top of a screen. It is clear while the large title is showing and glass once
+/// the list has scrolled under it, which is the only moment the chrome needs to separate itself.
+class NavBand extends StatelessWidget {
+ const NavBand({super.key, required this.title, this.largeTitle, this.leading, this.actions = const []});
+
+ final String title;
+
+ /// Replaces the plain large title, for a screen whose title is something to press.
+ final Widget? largeTitle;
+ final Widget? leading;
+ final List actions;
+
+ static const _largeTitleExtent = 52.0;
+
+ @override
+ Widget build(BuildContext context) {
+ return SliverPersistentHeader(
+ pinned: true,
+ delegate: _NavBandDelegate(
+ title: title,
+ largeTitle: largeTitle,
+ leading: leading,
+ actions: actions,
+ topPadding: MediaQuery.paddingOf(context).top,
+ ),
+ );
+ }
+}
+
+class _NavBandDelegate extends SliverPersistentHeaderDelegate {
+ _NavBandDelegate({
+ required this.title,
+ required this.largeTitle,
+ required this.leading,
+ required this.actions,
+ required this.topPadding,
+ });
+
+ final String title;
+ final Widget? largeTitle;
+ final Widget? leading;
+ final List actions;
+ final double topPadding;
+
+ @override
+ double get minExtent => topPadding + SpendableChrome.barHeight;
+
+ @override
+ double get maxExtent => minExtent + NavBand._largeTitleExtent;
+
+ @override
+ Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
+ final colors = SpendableColors.of(context);
+ final collapsed = (shrinkOffset / NavBand._largeTitleExtent).clamp(0.0, 1.0);
+
+ // The compact title only starts arriving once the large one is most of the way gone, so the two
+ // are never both legible at once.
+ final compact = ((collapsed - 0.6) / 0.4).clamp(0.0, 1.0);
+
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ // Skipped rather than faded out at the top of the list, so nothing is blurred behind a
+ // pane that is not there yet.
+ if (collapsed > 0)
+ Opacity(
+ opacity: collapsed,
+ child: GlassPanel(
+ blur: SpendableChrome.bandBlur,
+ tint: colors.band,
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: Container(height: 0.5, color: colors.separator),
+ ),
+ ),
+ ),
+ Positioned(
+ top: topPadding,
+ left: SpendableSpace.hair,
+ right: SpendableSpace.hair,
+ height: SpendableChrome.barHeight,
+ child: Row(
+ children: [
+ ?leading,
+ Expanded(
+ child: compact == 0
+ ? const SizedBox.shrink()
+ : Opacity(
+ opacity: compact,
+ child: Text(
+ title,
+ textAlign: TextAlign.center,
+ 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),
+ ],
+ ),
+ ),
+ Positioned(
+ left: SpendableSpace.gutter,
+ right: SpendableSpace.gutter,
+ bottom: SpendableSpace.tight,
+ child: Opacity(
+ opacity: 1 - collapsed,
+ child:
+ largeTitle ??
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: Alignment.centerLeft,
+ child: Text(title, style: SpendableType.largeTitle.copyWith(color: colors.primary)),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ @override
+ bool shouldRebuild(_NavBandDelegate old) =>
+ title != old.title ||
+ topPadding != old.topPadding ||
+ largeTitle != old.largeTitle ||
+ leading != old.leading ||
+ actions != old.actions;
+}
diff --git a/mobile/lib/design/picker_field.dart b/mobile/lib/design/picker_field.dart
new file mode 100644
index 00000000..37c2dccd
--- /dev/null
+++ b/mobile/lib/design/picker_field.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+
+import 'glyph_icon.dart';
+import 'tokens.dart';
+import 'typography.dart';
+
+/// Reads like the fields beside it, but opens a sheet instead of the keyboard. A dropdown would be
+/// the one Material shape left in a screen that has none.
+class PickerField extends StatelessWidget {
+ const PickerField({super.key, required this.label, required this.value, required this.onTap, this.error});
+
+ final String label;
+
+ /// Null reads as the placeholder, in the same grey as the label.
+ final String? value;
+ final VoidCallback onTap;
+ final String? error;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: SpendableSpace.tight),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ value ?? label,
+ overflow: TextOverflow.ellipsis,
+ style: SpendableType.body.copyWith(
+ color: value == null ? colors.secondary : colors.primary,
+ ),
+ ),
+ ),
+ GlyphIcon(Glyph.caretDown, size: 14, color: colors.tertiary),
+ ],
+ ),
+ ),
+ Container(height: 1, color: error == null ? colors.separator : colors.negative),
+ if (error case final error?)
+ Padding(
+ padding: const EdgeInsets.only(top: SpendableSpace.hair),
+ child: Text(error, style: SpendableType.subhead.copyWith(color: colors.negative)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/primary_button.dart b/mobile/lib/design/primary_button.dart
new file mode 100644
index 00000000..cc693f11
--- /dev/null
+++ b/mobile/lib/design/primary_button.dart
@@ -0,0 +1,66 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'tokens.dart';
+import 'typography.dart';
+
+/// Filled is the one thing a sheet is for; plain is an alternative offered rather than urged;
+/// destructive is a word in red, so archiving never competes with saving.
+enum ButtonVariant { filled, plain, destructive }
+
+class PrimaryButton extends StatelessWidget {
+ const PrimaryButton({
+ super.key,
+ required this.label,
+ required this.onPressed,
+ this.variant = ButtonVariant.filled,
+ });
+
+ final String label;
+ final VoidCallback? onPressed;
+ final ButtonVariant variant;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+ final enabled = onPressed != null;
+
+ final color = switch (variant) {
+ ButtonVariant.filled => colors.ground,
+ ButtonVariant.plain => colors.accent,
+ ButtonVariant.destructive => colors.negative,
+ };
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: enabled
+ ? () {
+ HapticFeedback.lightImpact();
+ onPressed!();
+ }
+ : null,
+ child: Container(
+ height: 50,
+ alignment: Alignment.center,
+ decoration: switch (variant) {
+ ButtonVariant.filled => BoxDecoration(
+ color: enabled ? colors.accent : colors.tertiary,
+ borderRadius: BorderRadius.circular(SpendableRadius.capsule),
+ ),
+ ButtonVariant.plain => BoxDecoration(
+ border: Border.all(color: colors.separator),
+ borderRadius: BorderRadius.circular(SpendableRadius.capsule),
+ ),
+ ButtonVariant.destructive => null,
+ },
+ child: Text(
+ label,
+ style: SpendableType.title.copyWith(
+ color: enabled ? color : colors.tertiary,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/section_rule.dart b/mobile/lib/design/section_rule.dart
new file mode 100644
index 00000000..036f09b9
--- /dev/null
+++ b/mobile/lib/design/section_rule.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+
+import 'caption.dart';
+import 'tokens.dart';
+
+/// Names what follows, with the rule running out from the caption to the edge. It is how the screen
+/// changes subject without a card or a heading.
+class SectionRule extends StatelessWidget {
+ const SectionRule(this.title, {super.key});
+
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(
+ SpendableSpace.gutter,
+ SpendableSpace.block,
+ SpendableSpace.gutter,
+ SpendableSpace.tight,
+ ),
+ child: Row(
+ children: [
+ Caption(title),
+ const SizedBox(width: SpendableSpace.step),
+ Expanded(child: Container(height: 1, color: colors.separator)),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/design/sheet_header.dart b/mobile/lib/design/sheet_header.dart
new file mode 100644
index 00000000..863f730b
--- /dev/null
+++ b/mobile/lib/design/sheet_header.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+
+import 'tokens.dart';
+import 'typography.dart';
+
+/// What a sheet is called, with the action that closes it kept beside the title rather than below
+/// the fold on a phone.
+class SheetHeader extends StatelessWidget {
+ const SheetHeader({super.key, required this.title, this.action});
+
+ final String title;
+ final Widget? action;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return Row(
+ children: [
+ Expanded(
+ child: Text(title, style: SpendableType.title.copyWith(color: colors.primary)),
+ ),
+ ?action,
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/design/theme.dart b/mobile/lib/design/theme.dart
new file mode 100644
index 00000000..18db7212
--- /dev/null
+++ b/mobile/lib/design/theme.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+import 'tokens.dart';
+import 'typography.dart';
+
+/// The app is drawn from [SpendableColors] and [SpendableType] directly; this only teaches the
+/// Material widgets that remain - fields, switches, pickers - to sit inside that.
+ThemeData spendableTheme(Brightness brightness) {
+ final colors = brightness == Brightness.dark ? SpendableColors.dark : SpendableColors.light;
+ final base = ThemeData(brightness: brightness, useMaterial3: true);
+
+ return base.copyWith(
+ scaffoldBackgroundColor: colors.ground,
+ canvasColor: colors.ground,
+ colorScheme: base.colorScheme.copyWith(
+ surface: colors.ground,
+ primary: colors.accent,
+ error: colors.negative,
+ onSurface: colors.primary,
+ ),
+ // Cupertino throughout, so a push slides and a swipe from the edge goes back.
+ platform: TargetPlatform.iOS,
+ pageTransitionsTheme: const PageTransitionsTheme(
+ builders: {TargetPlatform.iOS: CupertinoPageTransitionsBuilder()},
+ ),
+ splashFactory: NoSplash.splashFactory,
+ highlightColor: Colors.transparent,
+ textTheme: base.textTheme.apply(bodyColor: colors.primary, displayColor: colors.primary),
+ textSelectionTheme: TextSelectionThemeData(
+ cursorColor: colors.accent,
+ selectionColor: colors.accent.withValues(alpha: 0.3),
+ selectionHandleColor: colors.accent,
+ ),
+ // Fields are ruled like the rest of the content: a hairline under, nothing around.
+ inputDecorationTheme: InputDecorationTheme(
+ isDense: true,
+ filled: false,
+ contentPadding: const EdgeInsets.symmetric(vertical: SpendableSpace.tight),
+ labelStyle: SpendableType.body.copyWith(color: colors.secondary),
+ floatingLabelStyle: SpendableType.subhead.copyWith(color: colors.secondary),
+ errorStyle: SpendableType.subhead.copyWith(color: colors.negative),
+ enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.separator)),
+ focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.accent)),
+ errorBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.negative)),
+ focusedErrorBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.negative)),
+ ),
+ snackBarTheme: SnackBarThemeData(
+ behavior: SnackBarBehavior.floating,
+ backgroundColor: colors.primary,
+ contentTextStyle: SpendableType.body.copyWith(color: colors.ground),
+ ),
+ progressIndicatorTheme: ProgressIndicatorThemeData(color: colors.accent),
+ dividerTheme: DividerThemeData(color: colors.separator, space: 1, thickness: 1),
+ );
+}
diff --git a/mobile/lib/design/tokens.dart b/mobile/lib/design/tokens.dart
new file mode 100644
index 00000000..311e4208
--- /dev/null
+++ b/mobile/lib/design/tokens.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+
+/// The palette from the token sheet: content is flat and hairline-ruled, so there is no raised
+/// surface colour - only the ground, the rules drawn on it, and the glass every control is cut from.
+@immutable
+class SpendableColors {
+ const SpendableColors._({
+ required this.ground,
+ required this.separator,
+ required this.primary,
+ required this.secondary,
+ required this.tertiary,
+ required this.accent,
+ required this.positive,
+ required this.negative,
+ required this.trackingPill,
+ required this.chrome,
+ required this.band,
+ required this.menu,
+ required this.litEdge,
+ required this.ring,
+ required this.chromeShadow,
+ });
+
+ /// Brightness alone decides, so a screen mounted without the app's theme still reads right.
+ static SpendableColors of(BuildContext context) =>
+ Theme.of(context).brightness == Brightness.dark ? dark : light;
+
+ static const light = SpendableColors._(
+ ground: Color(0xFFF4F5F8),
+ separator: Color(0x2118181B),
+ primary: Color(0xFF18181B),
+ secondary: Color(0xFF5A5A63),
+ tertiary: Color(0xFFA0A0A9),
+ accent: Color(0xFF2E77C7),
+ positive: Color(0xFF146C43),
+ negative: Color(0xFFB4123B),
+ trackingPill: Color(0xFFA0A0A9),
+ chrome: Color(0xB8FFFFFF),
+ band: Color(0xB8FFFFFF),
+ menu: Color(0xCCFFFFFF),
+ litEdge: Color(0xF2FFFFFF),
+ ring: Color(0x8CFFFFFF),
+ chromeShadow: Color(0x29141C2D),
+ );
+
+ static const dark = SpendableColors._(
+ ground: Color(0xFF111827),
+ separator: Color(0x1FFFFFFF),
+ primary: Color(0xFFFFFFFF),
+ secondary: Color(0xFF9CA3AF),
+ tertiary: Color(0xFF6B7280),
+ accent: Color(0xFF65B0ED),
+ positive: Color(0xFF45DE8E),
+ negative: Color(0xFFF97066),
+ trackingPill: Color(0xFF9CA3AF),
+ chrome: Color(0x9E232C3E),
+ band: Color(0x94111827),
+ menu: Color(0xBD202838),
+ litEdge: Color(0x29FFFFFF),
+ ring: Color(0x1AFFFFFF),
+ chromeShadow: Color(0x80000000),
+ );
+
+ final Color ground;
+ final Color separator;
+ final Color primary;
+ final Color secondary;
+ final Color tertiary;
+ final Color accent;
+ final Color positive;
+ final Color negative;
+
+ /// Envelope and Goal pills borrow accent and positive; only Tracking has its own.
+ final Color trackingPill;
+
+ final Color chrome;
+ final Color band;
+ final Color menu;
+ final Color litEdge;
+ final Color ring;
+ final Color chromeShadow;
+
+ /// Money is read by sign before it is read as a number.
+ Color forAmount(num sign) => sign < 0 ? negative : primary;
+}
+
+/// The content scale. Chrome insets itself instead, at [SpendableChrome].
+abstract final class SpendableSpace {
+ static const hair = 4.0;
+ static const tight = 7.0;
+ static const step = 13.0;
+ static const gutter = 17.0;
+ static const block = 24.0;
+}
+
+/// Every row and rail is square; only glass is rounded.
+abstract final class SpendableRadius {
+ static const row = 0.0;
+ static const tabBar = 31.0;
+ static const capsule = 25.0;
+ static const menu = 16.0;
+}
+
+/// The floating tab bar's geometry, which every scroll view has to clear.
+abstract final class SpendableChrome {
+ static const inset = 14.0;
+
+ /// Measured from the bottom of the screen rather than from the safe area: a floating bar sits
+ /// over the home indicator's margin the way iOS does, not stacked on top of it.
+ static const bottomInset = 24.0;
+ static const tabBarHeight = 62.0;
+ static const barHeight = 44.0;
+ static const bandBlur = 26.0;
+ static const tabBarBlur = 28.0;
+ static const menuBlur = 30.0;
+
+ /// What the tab bar takes out of the bottom of the screen, safe area included.
+ static const tabBarExtent = tabBarHeight + bottomInset;
+}
diff --git a/mobile/lib/design/typography.dart b/mobile/lib/design/typography.dart
new file mode 100644
index 00000000..79c4d480
--- /dev/null
+++ b/mobile/lib/design/typography.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+
+/// The ramp from the token sheet. Letter spacing there is in em, and is multiplied out here
+/// because Flutter measures it in logical pixels.
+///
+/// No family is named, so iOS resolves the system face - SF Pro - rather than shipping a copy of it.
+abstract final class SpendableType {
+ /// Money always sets in tabular figures, so a decimal point stacks down a column.
+ static const _tabular = [FontFeature.tabularFigures()];
+
+ static const largeTitle = TextStyle(fontSize: 33, fontWeight: FontWeight.w600, letterSpacing: -0.73);
+ static const title = TextStyle(fontSize: 17, fontWeight: FontWeight.w500, letterSpacing: -0.17);
+ static const body = TextStyle(fontSize: 15, fontWeight: FontWeight.w500, letterSpacing: -0.15);
+ static const subhead = TextStyle(fontSize: 12, fontWeight: FontWeight.w400);
+
+ /// Always set through [Caption], which is what uppercases it.
+ static const caption = TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 1.17);
+
+ static const moneyHero = TextStyle(
+ fontSize: 37,
+ fontWeight: FontWeight.w500,
+ letterSpacing: -1.11,
+ fontFeatures: _tabular,
+ );
+
+ static const moneyRow = TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.w500,
+ letterSpacing: -0.53,
+ 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,
+ fontWeight: FontWeight.w500,
+ letterSpacing: -0.15,
+ fontFeatures: _tabular,
+ );
+}
diff --git a/mobile/lib/shell.dart b/mobile/lib/shell.dart
index b0d4c8fe..b3ac87fb 100644
--- a/mobile/lib/shell.dart
+++ b/mobile/lib/shell.dart
@@ -1,12 +1,29 @@
+import 'dart:math' as math;
+
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'banks/banks_screen.dart';
import 'budgets/budgets_screen.dart';
+import 'design/glass_tab_bar.dart';
+import 'design/glyph_icon.dart';
+import 'design/tokens.dart';
import 'finance_kit/wallet_sync.dart';
import 'splits/splits_screen.dart';
import 'transactions/transactions_screen.dart';
+const _destinations = [
+ TabDestination(key: Key('tab-budgets'), icon: Glyph.money, fill: Glyph.moneyFill, label: 'Budgets'),
+ TabDestination(
+ key: Key('tab-transactions'),
+ icon: Glyph.creditCard,
+ fill: Glyph.creditCardFill,
+ label: 'Transactions',
+ ),
+ TabDestination(key: Key('tab-splits'), icon: Glyph.copy, fill: Glyph.copyFill, label: 'Splits'),
+ TabDestination(key: Key('tab-banks'), icon: Glyph.bank, fill: Glyph.bankFill, label: 'Banks'),
+];
+
/// IndexedStack rather than a swapped child, so switching tabs keeps each screen's scroll
/// position and its loaded pages.
class Shell extends ConsumerStatefulWidget {
@@ -21,44 +38,44 @@ class _ShellState extends ConsumerState {
@override
Widget build(BuildContext context) {
+ final media = MediaQuery.of(context);
+
// Here rather than in the app, so nothing reads Wallet before there is a signed-in user to
// send it to. Nothing renders it; it only has to be alive.
ref.watch(walletAutoSyncProvider);
- return Scaffold(
- body: IndexedStack(
- index: _tab,
- children: const [BudgetsScreen(), TransactionsScreen(), SplitsScreen(), BanksScreen()],
- ),
- bottomNavigationBar: NavigationBar(
- selectedIndex: _tab,
- onDestinationSelected: (index) => setState(() => _tab = index),
- destinations: const [
- NavigationDestination(
- key: Key('tab-budgets'),
- icon: Icon(Icons.pie_chart_outline),
- selectedIcon: Icon(Icons.pie_chart),
- label: 'Budgets',
- ),
- NavigationDestination(
- key: Key('tab-transactions'),
- icon: Icon(Icons.receipt_long_outlined),
- selectedIcon: Icon(Icons.receipt_long),
- label: 'Transactions',
- ),
- NavigationDestination(
- key: Key('tab-splits'),
- icon: Icon(Icons.call_split_outlined),
- selectedIcon: Icon(Icons.call_split),
- label: 'Splits',
- ),
- NavigationDestination(
- key: Key('tab-banks'),
- icon: Icon(Icons.account_balance_outlined),
- selectedIcon: Icon(Icons.account_balance),
- label: 'Banks',
- ),
- ],
+ return BackdropGroup(
+ child: Scaffold(
+ backgroundColor: SpendableColors.of(context).ground,
+ // The bar is outside every screen's own Scaffold, and text with no Material above it falls
+ // back to the framework's error style rather than the theme's.
+ body: Stack(
+ children: [
+ // The bar floats over the list rather than sitting beside it, so what every screen has to
+ // clear is handed down as padding instead of each one knowing the bar's height.
+ MediaQuery(
+ data: media.copyWith(
+ padding: media.padding.copyWith(
+ bottom: math.max(media.padding.bottom, SpendableChrome.tabBarExtent),
+ ),
+ ),
+ child: IndexedStack(
+ index: _tab,
+ children: const [BudgetsScreen(), TransactionsScreen(), SplitsScreen(), BanksScreen()],
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: GlassTabBar(
+ destinations: _destinations,
+ selectedIndex: _tab,
+ onSelected: (index) => setState(() => _tab = index),
+ ),
+ ),
+ ],
+ ),
),
);
}
diff --git a/mobile/lib/splits/split_form.dart b/mobile/lib/splits/split_form.dart
index 47183e48..79d71393 100644
--- a/mobile/lib/splits/split_form.dart
+++ b/mobile/lib/splits/split_form.dart
@@ -4,7 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
import '../api/api_error.dart';
+import '../budgets/budget_picker.dart';
import '../budgets/budgets_providers.dart';
+import '../design/band_button.dart';
+import '../design/glyph_icon.dart';
+import '../design/picker_field.dart';
+import '../design/primary_button.dart';
+import '../design/sheet_header.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import 'splits_controller.dart';
class _Line {
@@ -46,6 +54,7 @@ class _SplitFormState extends ConsumerState {
@override
Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
final state = ref.watch(splitsControllerProvider);
final errors = state.error is ApiError
? (state.error! as ApiError).fieldErrors
@@ -54,29 +63,22 @@ class _SplitFormState extends ConsumerState {
return Padding(
padding: EdgeInsets.only(
- left: 20,
- right: 20,
- top: 20,
- bottom: MediaQuery.of(context).viewInsets.bottom + 20,
+ left: SpendableSpace.gutter,
+ right: SpendableSpace.gutter,
+ top: SpendableSpace.step,
+ bottom: MediaQuery.viewInsetsOf(context).bottom + SpendableSpace.block,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Row(
- children: [
- Expanded(
- child: Text(
- widget.split == null ? 'New split' : 'Edit split',
- style: Theme.of(context).textTheme.titleLarge,
- ),
- ),
- TextButton(
- key: const Key('split-save'),
- onPressed: state.isLoading ? null : _save,
- child: const Text('Save'),
- ),
- ],
+ SheetHeader(
+ title: widget.split == null ? 'New split' : 'Edit split',
+ action: BandButton(
+ key: const Key('split-save'),
+ label: 'Save',
+ onPressed: state.isLoading ? null : _save,
+ ),
),
Flexible(
child: SingleChildScrollView(
@@ -84,35 +86,32 @@ class _SplitFormState extends ConsumerState {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- const SizedBox(height: 8),
TextField(
key: const Key('split-name'),
controller: _name,
+ style: SpendableType.body.copyWith(color: colors.primary),
decoration: InputDecoration(labelText: 'Name', errorText: errors['/name']),
),
- const SizedBox(height: 16),
+ const SizedBox(height: SpendableSpace.gutter),
for (final (index, line) in _lines.indexed)
Padding(
- padding: const EdgeInsets.only(bottom: 8),
+ padding: const EdgeInsets.only(bottom: SpendableSpace.tight),
child: Row(
children: [
Expanded(
flex: 3,
- child: DropdownButtonFormField(
+ child: PickerField(
key: Key('split-budget-$index'),
- initialValue: line.budgetId,
- isExpanded: true,
- items: [
- for (final budget in budgets)
- DropdownMenuItem(
- value: budget.id,
- child: Text(budget.name, overflow: TextOverflow.ellipsis),
- ),
- ],
- onChanged: (value) => setState(() => line.budgetId = value),
+ label: 'Budget',
+ value: budgets.where((budget) => budget.id == line.budgetId).firstOrNull?.name,
+ onTap: () async {
+ final chosen = await pickBudget(context, ref);
+
+ if (chosen != null) setState(() => line.budgetId = chosen.id);
+ },
),
),
- const SizedBox(width: 8),
+ const SizedBox(width: SpendableSpace.tight),
Expanded(
flex: 2,
child: TextField(
@@ -122,32 +121,43 @@ class _SplitFormState extends ConsumerState {
decimal: true,
signed: true,
),
+ textAlign: TextAlign.right,
+ style: SpendableType.moneyInline.copyWith(color: colors.primary),
decoration: InputDecoration(errorText: errors['/split_lines/$index/amount']),
),
),
- IconButton(
+ GestureDetector(
key: Key('split-remove-$index'),
- icon: const Icon(Icons.remove_circle_outline),
- onPressed: () => setState(() => _lines = [..._lines]..removeAt(index)),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => setState(() => _lines = [..._lines]..removeAt(index)),
+ child: SizedBox(
+ width: 40,
+ height: 40,
+ child: Center(
+ child: GlyphIcon(Glyph.minusCircle, size: 20, color: colors.tertiary),
+ ),
+ ),
),
],
),
),
Align(
alignment: Alignment.centerLeft,
- child: TextButton(
+ child: BandButton(
key: const Key('split-add-line'),
+ label: 'Add line',
onPressed: () => setState(() => _lines = [..._lines, _Line(amount: '')]),
- child: const Text('Add line'),
),
),
- if (widget.split case final split?)
- TextButton(
+ if (widget.split case final split?) ...[
+ const SizedBox(height: SpendableSpace.step),
+ PrimaryButton(
key: const Key('split-archive'),
+ label: 'Archive split',
+ variant: ButtonVariant.destructive,
onPressed: state.isLoading ? null : () => _archive(split.id),
- style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
- child: const Text('Archive split'),
),
+ ],
],
),
),
diff --git a/mobile/lib/splits/splits_screen.dart b/mobile/lib/splits/splits_screen.dart
index 777900dd..5a6f9937 100644
--- a/mobile/lib/splits/splits_screen.dart
+++ b/mobile/lib/splits/splits_screen.dart
@@ -1,10 +1,19 @@
import 'package:flutter/material.dart' hide Split;
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
import '../api/api_error.dart';
+import '../design/band_button.dart';
+import '../design/glass_sheet.dart';
+import '../design/glyph_icon.dart';
+import '../design/ledger_row.dart';
+import '../design/ledger_screen.dart';
+import '../design/money_text.dart';
+import '../design/nav_band.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import '../money.dart';
-import '../theme.dart';
import 'split_form.dart';
import 'splits_controller.dart';
import 'splits_providers.dart';
@@ -26,71 +35,95 @@ class SplitsScreen extends ConsumerWidget {
}
});
- return Scaffold(
- appBar: AppBar(
- title: const Text('Splits'),
+ return LedgerScreen(
+ onRefresh: () async => ref.invalidate(splitsProvider),
+ band: NavBand(
+ title: 'Splits',
actions: [
if (selection.isNotEmpty)
- TextButton(
+ BandButton(
key: const Key('archive-selected'),
+ label: 'Archive (${selection.length})',
onPressed: () => ref.read(splitsControllerProvider.notifier).archive(selection),
- child: Text('Archive (${selection.length})'),
),
- IconButton(
- key: const Key('new-split'),
- icon: const Icon(Icons.add),
- onPressed: () => openSplitForm(context),
- ),
+ BandButton(key: const Key('new-split'), icon: Glyph.plus, onPressed: () => openSplitForm(context)),
],
),
- body: RefreshIndicator(
- onRefresh: () async => ref.invalidate(splitsProvider),
- child: switch (splits) {
- AsyncData(value: final splits) when splits.isEmpty => const _Message('No splits yet.'),
- AsyncData(value: final splits) => ListView.separated(
+ slivers: switch (splits) {
+ AsyncData(value: final splits) when splits.isEmpty => const [_Message('No splits yet.')],
+ AsyncData(value: final splits) => [
+ SliverList.builder(
itemCount: splits.length,
- separatorBuilder: (_, _) => const Divider(height: 1),
- itemBuilder: (_, index) =>
- _Row(split: splits[index], selected: selection.contains(splits[index].id)),
+ itemBuilder: (_, index) => _Row(
+ split: splits[index],
+ selected: selection.contains(splits[index].id),
+ selecting: selection.isNotEmpty,
+ ),
),
- AsyncError(:final error) => _Message('$error'),
- _ => const Center(child: CircularProgressIndicator()),
- },
- ),
+ ],
+ AsyncError(:final error) => [_Message('$error')],
+ _ => const [
+ SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())),
+ ],
+ },
);
}
}
-Future openSplitForm(BuildContext context, {Split? split}) => showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- builder: (_) => SplitForm(split: split),
-);
+Future openSplitForm(BuildContext context, {Split? split}) =>
+ showGlassSheet(context, (_) => SplitForm(split: split));
class _Row extends ConsumerWidget {
- const _Row({required this.split, required this.selected});
+ const _Row({required this.split, required this.selected, required this.selecting});
final Split split;
final bool selected;
+ final bool selecting;
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
final total = split.splitLines.fold(money('0'), (sum, line) => sum + money(line.amount));
- return ListTile(
+ return LedgerRow(
key: Key('split-${split.id}'),
+ selected: selected,
onTap: () => openSplitForm(context, split: split),
- leading: Checkbox(
- key: Key('select-split-${split.id}'),
- value: selected,
- onChanged: (_) => ref.read(splitSelectionProvider.notifier).toggle(split.id),
- ),
- title: Text(split.name),
- subtitle: Text(
- '${split.splitLines.length} ${split.splitLines.length == 1 ? 'line' : 'lines'}',
- style: const TextStyle(color: SpendableColors.muted, fontSize: 12),
+ onLongPress: () {
+ HapticFeedback.selectionClick();
+ ref.read(splitSelectionProvider.notifier).toggle(split.id);
+ },
+ child: Row(
+ children: [
+ if (selecting)
+ GestureDetector(
+ key: Key('select-split-${split.id}'),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => ref.read(splitSelectionProvider.notifier).toggle(split.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(split.name, style: SpendableType.title.copyWith(color: colors.primary)),
+ Text(
+ '${split.splitLines.length} ${split.splitLines.length == 1 ? 'line' : 'lines'}',
+ style: SpendableType.subhead.copyWith(color: colors.secondary),
+ ),
+ ],
+ ),
+ ),
+ MoneyText(total, style: SpendableType.moneyRow),
+ ],
),
- trailing: Text(formatCurrency(total), style: const TextStyle(fontWeight: FontWeight.w600)),
);
}
}
@@ -102,13 +135,18 @@ class _Message extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(32),
- child: Text(text, textAlign: TextAlign.center),
+ return SliverFillRemaining(
+ hasScrollBody: false,
+ child: Padding(
+ padding: const EdgeInsets.all(SpendableSpace.block),
+ child: Center(
+ child: Text(
+ text,
+ textAlign: TextAlign.center,
+ style: SpendableType.body.copyWith(color: SpendableColors.of(context).secondary),
+ ),
),
- ],
+ ),
);
}
}
diff --git a/mobile/lib/theme.dart b/mobile/lib/theme.dart
deleted file mode 100644
index 9f297937..00000000
--- a/mobile/lib/theme.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-import 'package:flutter/material.dart';
-
-/// Matches the web app: near-black ground, white text, blue for actions, green and red for
-/// money that is or is not there.
-abstract final class SpendableColors {
- static const background = Color(0xFF0B0E14);
- static const surface = Color(0xFF141922);
- static const accent = Color(0xFF60A5FA);
- static const positive = Color(0xFF4ADE80);
- static const negative = Color(0xFFF87171);
- static const muted = Color(0xFF9CA3AF);
-}
-
-ThemeData spendableTheme() {
- final base = ThemeData.dark(useMaterial3: true);
-
- return base.copyWith(
- scaffoldBackgroundColor: SpendableColors.background,
- colorScheme: base.colorScheme.copyWith(
- surface: SpendableColors.surface,
- primary: SpendableColors.accent,
- error: SpendableColors.negative,
- ),
- appBarTheme: const AppBarTheme(
- backgroundColor: SpendableColors.background,
- surfaceTintColor: Colors.transparent,
- centerTitle: false,
- ),
- dividerTheme: const DividerThemeData(color: Colors.white12, space: 1, thickness: 1),
- );
-}
diff --git a/mobile/lib/transactions/transaction_detail.dart b/mobile/lib/transactions/transaction_detail.dart
index a18e5c10..e5d6b9d8 100644
--- a/mobile/lib/transactions/transaction_detail.dart
+++ b/mobile/lib/transactions/transaction_detail.dart
@@ -1,14 +1,26 @@
import 'package:built_collection/built_collection.dart';
import 'package:decimal/decimal.dart';
+import 'package:flutter/cupertino.dart' hide Split;
import 'package:flutter/material.dart' hide Split;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spendable_api/spendable_api.dart';
import '../api/api_error.dart';
-import '../theme.dart';
+import '../budgets/budget_picker.dart';
import '../budgets/budgets_providers.dart';
+import '../design/band_button.dart';
+import '../design/caption.dart';
+import '../design/glass.dart';
+import '../design/glass_sheet.dart';
+import '../design/glyph_icon.dart';
+import '../design/ledger_row.dart';
+import '../design/picker_field.dart';
+import '../design/sheet_header.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import '../splits/splits_providers.dart';
import 'transactions_controller.dart';
+import 'transactions_screen.dart';
/// One allocation being edited. Kept apart from the wire type because a line the user is still
/// typing has no valid amount yet.
@@ -58,6 +70,7 @@ class _TransactionDetailState extends ConsumerState {
@override
Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
final state = ref.watch(transactionsControllerProvider);
final errors = state.error is ApiError
? (state.error! as ApiError).fieldErrors
@@ -69,10 +82,10 @@ class _TransactionDetailState extends ConsumerState {
return Padding(
padding: EdgeInsets.only(
- left: 20,
- right: 20,
- top: 20,
- bottom: MediaQuery.of(context).viewInsets.bottom + 20,
+ left: SpendableSpace.gutter,
+ right: SpendableSpace.gutter,
+ top: SpendableSpace.step,
+ bottom: MediaQuery.viewInsetsOf(context).bottom + SpendableSpace.block,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -80,15 +93,13 @@ class _TransactionDetailState extends ConsumerState {
children: [
// Save stays put: the fields below it scroll, and on a phone the button would
// otherwise sit past the fold.
- Row(
- children: [
- Expanded(child: Text('Edit transaction', style: Theme.of(context).textTheme.titleLarge)),
- TextButton(
- key: const Key('transaction-save'),
- onPressed: state.isLoading ? null : _save,
- child: const Text('Save'),
- ),
- ],
+ SheetHeader(
+ title: 'Edit transaction',
+ action: BandButton(
+ key: const Key('transaction-save'),
+ label: 'Save',
+ onPressed: state.isLoading ? null : _save,
+ ),
),
Flexible(
child: SingleChildScrollView(
@@ -96,70 +107,67 @@ class _TransactionDetailState extends ConsumerState {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- const SizedBox(height: 8),
TextField(
key: const Key('transaction-name'),
controller: _name,
+ style: SpendableType.body.copyWith(color: colors.primary),
decoration: InputDecoration(labelText: 'Name', errorText: errors['/name']),
),
- const SizedBox(height: 16),
+ const SizedBox(height: SpendableSpace.gutter),
TextField(
key: const Key('transaction-amount'),
controller: _amount,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
+ style: SpendableType.moneyInline.copyWith(color: colors.primary),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(labelText: 'Amount', errorText: errors['/amount']),
),
- const SizedBox(height: 8),
- ListTile(
+ const SizedBox(height: SpendableSpace.tight),
+ PickerField(
key: const Key('transaction-date'),
- contentPadding: EdgeInsets.zero,
- title: const Text('Date'),
- trailing: Text('$_date'),
+ label: 'Date',
+ value: shortDate(_date),
onTap: _pickDate,
),
- const Divider(height: 24),
+ const SizedBox(height: SpendableSpace.block),
_allocations(budgets, splits, errors),
- const SizedBox(height: 16),
+ const SizedBox(height: SpendableSpace.gutter),
TextField(
key: const Key('transaction-note'),
controller: _note,
maxLines: 3,
+ style: SpendableType.body.copyWith(color: colors.primary),
decoration: const InputDecoration(labelText: 'Note'),
),
- if (widget.transaction.transferId != null) ...[
- const SizedBox(height: 8),
- ListTile(
- contentPadding: EdgeInsets.zero,
- title: const Text('Part of a transfer'),
- trailing: TextButton(
- key: const Key('remove-transfer'),
- onPressed: state.isLoading ? null : _removeTransfer,
- child: const Text('Remove'),
+ if (widget.transaction.transferId != null)
+ LedgerRow(
+ ruleInset: 0,
+ padding: const EdgeInsets.symmetric(vertical: SpendableSpace.tight),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Part of a transfer',
+ style: SpendableType.body.copyWith(color: colors.primary),
+ ),
+ ),
+ BandButton(
+ key: const Key('remove-transfer'),
+ label: 'Remove',
+ onPressed: state.isLoading ? null : _removeTransfer,
+ ),
+ ],
),
),
- ],
- Row(
- children: [
- Expanded(
- child: CheckboxListTile(
- key: const Key('transaction-reviewed'),
- contentPadding: EdgeInsets.zero,
- title: const Text('Reviewed'),
- value: _reviewed,
- onChanged: (value) => setState(() => _reviewed = value ?? false),
- ),
- ),
- Expanded(
- child: CheckboxListTile(
- key: const Key('transaction-excluded'),
- contentPadding: EdgeInsets.zero,
- title: const Text('Excluded'),
- value: _excluded,
- onChanged: (value) => setState(() => _excluded = value ?? false),
- ),
- ),
- ],
+ _Toggle(
+ label: 'Reviewed',
+ value: _reviewed,
+ onChanged: (value) => setState(() => _reviewed = value),
+ ),
+ _Toggle(
+ label: 'Excluded',
+ value: _excluded,
+ onChanged: (value) => setState(() => _excluded = value),
),
],
),
@@ -175,48 +183,53 @@ class _TransactionDetailState extends ConsumerState {
// only the budget is worth asking about.
final single = _lines.length <= 1;
final negative = (Decimal.tryParse(_amount.text.trim()) ?? Decimal.zero).sign < 0;
+ final colors = SpendableColors.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Text(negative ? 'Spend from' : 'Add to', style: const TextStyle(color: SpendableColors.muted)),
- const SizedBox(height: 8),
+ Caption(negative ? 'Spend from' : 'Add to'),
+ const SizedBox(height: SpendableSpace.tight),
for (final (index, line) in _lines.indexed)
Padding(
- padding: const EdgeInsets.only(bottom: 8),
+ padding: const EdgeInsets.only(bottom: SpendableSpace.tight),
child: Row(
children: [
Expanded(
flex: 3,
- child: DropdownButtonFormField(
+ child: PickerField(
key: Key('allocation-budget-$index'),
- initialValue: line.budgetId,
- isExpanded: true,
- items: [
- for (final budget in budgets)
- DropdownMenuItem(
- value: budget.id,
- child: Text(budget.name, overflow: TextOverflow.ellipsis),
- ),
- ],
- onChanged: (value) => setState(() => line.budgetId = value),
+ label: 'Budget',
+ value: budgets.where((budget) => budget.id == line.budgetId).firstOrNull?.name,
+ onTap: () async {
+ final chosen = await pickBudget(context, ref);
+
+ if (chosen != null) setState(() => line.budgetId = chosen.id);
+ },
),
),
if (!single) ...[
- const SizedBox(width: 8),
+ const SizedBox(width: SpendableSpace.tight),
Expanded(
flex: 2,
child: TextField(
key: Key('allocation-amount-$index'),
controller: line.amount,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
+ textAlign: TextAlign.right,
+ style: SpendableType.moneyInline.copyWith(color: colors.primary),
decoration: InputDecoration(errorText: errors['/budget_allocations/$index/amount']),
),
),
- IconButton(
+ GestureDetector(
key: Key('allocation-remove-$index'),
- icon: const Icon(Icons.remove_circle_outline),
- onPressed: () => setState(() => _lines = [..._lines]..removeAt(index)),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => setState(() => _lines = [..._lines]..removeAt(index)),
+ child: SizedBox(
+ width: 40,
+ height: 40,
+ child: Center(child: GlyphIcon(Glyph.minusCircle, size: 20, color: colors.tertiary)),
+ ),
),
],
],
@@ -225,11 +238,11 @@ class _TransactionDetailState extends ConsumerState {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- TextButton(key: const Key('add-line'), onPressed: _addLine, child: const Text('Add line')),
- TextButton(
+ BandButton(key: const Key('add-line'), label: 'Add line', onPressed: _addLine),
+ BandButton(
key: const Key('apply-split'),
+ label: 'Apply split',
onPressed: () => _pickSplit(splits),
- child: const Text('Apply split'),
),
],
),
@@ -239,28 +252,48 @@ class _TransactionDetailState extends ConsumerState {
void _addLine() => setState(() => _lines = [..._lines, _Line(budgetId: null, amount: '')]);
+ /// The wheel, on the same glass as everything else, rather than the Material calendar.
Future _pickDate() async {
- final picked = await showDatePicker(
+ final colors = SpendableColors.of(context);
+
+ await showCupertinoModalPopup(
context: context,
- initialDate: _date.toDateTime(),
- firstDate: DateTime(2000),
- lastDate: DateTime(2100),
+ builder: (context) => GlassPanel(
+ blur: SpendableChrome.menuBlur,
+ tint: colors.menu,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(SpendableRadius.menu)),
+ shadow: true,
+ child: SizedBox(
+ height: 260,
+ child: CupertinoDatePicker(
+ mode: CupertinoDatePickerMode.date,
+ initialDateTime: _date.toDateTime(),
+ minimumYear: 2000,
+ maximumYear: 2100,
+ onDateTimeChanged: (picked) =>
+ setState(() => _date = Date(picked.year, picked.month, picked.day)),
+ ),
+ ),
+ ),
);
-
- if (picked != null) setState(() => _date = Date(picked.year, picked.month, picked.day));
}
/// Applying a split only rewrites the lines here; the server sees them on save like any others.
Future _pickSplit(List splits) async {
- final chosen = await showModalBottomSheet(
- context: context,
- builder: (_) => ListView(
+ final chosen = await showGlassSheet(
+ context,
+ (context) => ListView(
+ shrinkWrap: true,
+ padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
children: [
for (final split in splits)
- ListTile(
+ LedgerRow(
key: Key('split-${split.id}'),
- title: Text(split.name),
onTap: () => Navigator.of(context).pop(split),
+ child: Text(
+ split.name,
+ style: SpendableType.title.copyWith(color: SpendableColors.of(context).primary),
+ ),
),
],
),
@@ -310,3 +343,31 @@ class _TransactionDetailState extends ConsumerState {
if (removed && mounted) Navigator.of(context).pop();
}
}
+
+class _Toggle extends StatelessWidget {
+ const _Toggle({required this.label, required this.value, required this.onChanged});
+
+ final String label;
+ final bool value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return LedgerRow(
+ key: Key('transaction-${label.toLowerCase()}'),
+ ruleInset: 0,
+ padding: const EdgeInsets.symmetric(vertical: SpendableSpace.hair),
+ onTap: () => onChanged(!value),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(label, style: SpendableType.body.copyWith(color: colors.primary)),
+ ),
+ Switch.adaptive(value: value, onChanged: onChanged),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/transactions/transactions_screen.dart b/mobile/lib/transactions/transactions_screen.dart
index 637e52a6..82c4fac0 100644
--- a/mobile/lib/transactions/transactions_screen.dart
+++ b/mobile/lib/transactions/transactions_screen.dart
@@ -1,11 +1,23 @@
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 '../budgets/budgets_providers.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';
+import '../design/ledger_row.dart';
+import '../design/ledger_screen.dart';
+import '../design/money_text.dart';
+import '../design/nav_band.dart';
+import '../design/tokens.dart';
+import '../design/typography.dart';
import '../money.dart';
-import '../theme.dart';
import 'transaction_detail.dart';
import 'transactions_controller.dart';
import 'transactions_providers.dart';
@@ -44,40 +56,6 @@ class TransactionsScreen extends ConsumerWidget {
}
});
- return Scaffold(
- appBar: AppBar(
- title: const Text('Transactions'),
- actions: [
- IconButton(
- key: const Key('open-filters'),
- icon: const Icon(Icons.filter_list),
- onPressed: () => showModalBottomSheet(context: context, builder: (_) => const _Filters()),
- ),
- ],
- ),
- bottomNavigationBar: selection.isEmpty ? null : _BulkActions(selection: selection),
- body: RefreshIndicator(
- onRefresh: () async => ref.invalidate(transactionsProvider),
- child: switch (page) {
- AsyncData(value: final page) => _List(page: page, selection: selection),
- AsyncError(:final error) => _Message('$error'),
- _ => const Center(child: CircularProgressIndicator()),
- },
- ),
- );
- }
-}
-
-class _List extends ConsumerWidget {
- const _List({required this.page, required this.selection});
-
- final TransactionPage page;
- final Set selection;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- if (page.transactions.isEmpty) return const _Message('Nothing to review.');
-
return NotificationListener(
onNotification: (notification) {
final metrics = notification.metrics;
@@ -88,79 +66,186 @@ class _List extends ConsumerWidget {
return false;
},
- child: ListView.separated(
- itemCount: page.transactions.length,
- separatorBuilder: (_, _) => const Divider(height: 1),
- itemBuilder: (context, index) {
- final transaction = page.transactions[index];
-
- return _Row(transaction: transaction, selected: selection.contains(transaction.id));
+ child: LedgerScreen(
+ onRefresh: () async => ref.invalidate(transactionsProvider),
+ band: NavBand(
+ title: 'Transactions',
+ actions: [
+ BandButton(
+ key: const Key('open-filters'),
+ icon: Glyph.funnel,
+ onPressed: () => showGlassSheet(context, (_) => const _Filters()),
+ ),
+ ],
+ ),
+ bottomBar: selection.isEmpty ? null : _BulkActions(selection: selection),
+ slivers: switch (page) {
+ AsyncData(value: final page) => _list(page, selection),
+ AsyncError(:final error) => [_Message('$error')],
+ _ => const [
+ SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())),
+ ],
},
),
);
}
+
+ List _list(TransactionPage page, Set selection) {
+ if (page.transactions.isEmpty) return const [_Message('Nothing to review.')];
+
+ return [
+ SliverList.builder(
+ itemCount: page.transactions.length,
+ itemBuilder: (_, index) => _Row(
+ transaction: page.transactions[index],
+ selected: selection.contains(page.transactions[index].id),
+ selecting: selection.isNotEmpty,
+ ),
+ ),
+ ];
+ }
}
class _Row extends ConsumerWidget {
- const _Row({required this.transaction, required this.selected});
+ const _Row({required this.transaction, required this.selected, required this.selecting});
final Transaction transaction;
final bool selected;
+ final bool selecting;
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
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 Opacity(
- opacity: dimmed ? 0.4 : 1,
- child: ListTile(
+ 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,
+ children: [
+ _SwipeAction(label: 'Spend from', color: colors.accent, onPressed: () => _spendFrom(context, ref)),
+ ],
+ ),
+ child: LedgerRow(
key: Key('transaction-${transaction.id}'),
- onTap: () => showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- builder: (_) => TransactionDetail(transaction: transaction),
- ),
- onLongPress: () => ref.read(selectionProvider.notifier).toggle(transaction.id),
- leading: Checkbox(
- key: Key('select-${transaction.id}'),
- value: selected,
- onChanged: (_) => ref.read(selectionProvider.notifier).toggle(transaction.id),
- ),
- title: Text(transaction.name, overflow: TextOverflow.ellipsis),
- subtitle: Text(
- [
- shortDate(transaction.date),
- if (source != null) '${source.accountName} ••••${source.accountNumber ?? ''}',
- if (transaction.transferId != null) 'Transfer',
- ].join(' · '),
- style: const TextStyle(color: SpendableColors.muted, fontSize: 12),
- overflow: TextOverflow.ellipsis,
- ),
- trailing: Row(
- mainAxisSize: MainAxisSize.min,
+ selected: selected,
+ dimmed: dimmed,
+ onTap: () => showGlassSheet(context, (_) => TransactionDetail(transaction: transaction)),
+ onLongPress: () {
+ HapticFeedback.selectionClick();
+ ref.read(selectionProvider.notifier).toggle(transaction.id);
+ },
+ child: Row(
children: [
- Text(
- formatCurrency(money(transaction.amount)),
- style: const TextStyle(fontWeight: FontWeight.w600),
+ 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,
+ ),
+ ],
+ ),
),
- IconButton(
+ const SizedBox(width: SpendableSpace.step),
+ MoneyText(money(transaction.amount), style: SpendableType.moneyRow, creditIsPositive: true),
+ GestureDetector(
key: Key('reviewed-${transaction.id}'),
- tooltip: transaction.reviewed ? 'Mark unreviewed' : 'Mark reviewed',
- icon: Icon(
- transaction.reviewed ? Icons.check_circle : Icons.circle_outlined,
- color: transaction.reviewed ? SpendableColors.positive : SpendableColors.muted,
+ 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,
+ ),
+ ),
),
- onPressed: () => controller.toggleReviewed(transaction),
),
],
),
),
);
}
+
+ 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)),
+ ),
+ ),
+ );
+ }
}
class _Filters extends ConsumerWidget {
@@ -171,35 +256,65 @@ class _Filters extends ConsumerWidget {
final filters = ref.watch(filtersProvider);
return SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
- child: TextField(
- key: const Key('transaction-search'),
- decoration: const InputDecoration(labelText: 'Search name or note'),
- onSubmitted: ref.read(filtersProvider.notifier).search,
+ child: Padding(
+ padding: const EdgeInsets.only(top: SpendableSpace.tight),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: SpendableSpace.gutter),
+ child: TextField(
+ key: const Key('transaction-search'),
+ decoration: const InputDecoration(labelText: 'Search name or note'),
+ onSubmitted: ref.read(filtersProvider.notifier).search,
+ ),
),
+ const SizedBox(height: SpendableSpace.step),
+ _Toggle(
+ key: const Key('filter-reviewed'),
+ label: 'Show reviewed transactions',
+ value: filters.showReviewed,
+ onChanged: ref.read(filtersProvider.notifier).toggleReviewed,
+ ),
+ _Toggle(
+ key: const Key('filter-excluded'),
+ label: 'Show excluded transactions',
+ value: filters.showExcluded,
+ onChanged: ref.read(filtersProvider.notifier).toggleExcluded,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _Toggle extends StatelessWidget {
+ const _Toggle({super.key, required this.label, required this.value, required this.onChanged});
+
+ final String label;
+ final bool value;
+ final VoidCallback onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = SpendableColors.of(context);
+
+ return LedgerRow(
+ onTap: onChanged,
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(label, style: SpendableType.body.copyWith(color: colors.primary)),
),
- SwitchListTile(
- key: const Key('filter-reviewed'),
- title: const Text('Show reviewed transactions'),
- value: filters.showReviewed,
- onChanged: (_) => ref.read(filtersProvider.notifier).toggleReviewed(),
- ),
- SwitchListTile(
- key: const Key('filter-excluded'),
- title: const Text('Show excluded transactions'),
- value: filters.showExcluded,
- onChanged: (_) => ref.read(filtersProvider.notifier).toggleExcluded(),
- ),
+ Switch.adaptive(value: value, onChanged: (_) => onChanged()),
],
),
);
}
}
+/// What can be done to a selection, on the same glass as the tab bar it sits above.
class _BulkActions extends ConsumerWidget {
const _BulkActions({required this.selection});
@@ -207,78 +322,84 @@ class _BulkActions extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final colors = SpendableColors.of(context);
final controller = ref.read(transactionsControllerProvider.notifier);
- return BottomAppBar(
- color: SpendableColors.surface,
- child: Row(
- children: [
- IconButton(
- key: const Key('bulk-clear'),
- icon: const Icon(Icons.close),
- onPressed: () => ref.read(selectionProvider.notifier).clear(),
- ),
- Text('${selection.length}'),
- // 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: [
- TextButton(
- key: const Key('bulk-review'),
- onPressed: () => controller.bulk(ids: selection, reviewed: true),
- child: const Text('Review'),
- ),
- TextButton(
- key: const Key('bulk-exclude'),
- onPressed: () => controller.bulk(ids: selection, excluded: true),
- child: const Text('Exclude'),
- ),
- TextButton(
- key: const Key('bulk-spend-from'),
- onPressed: () => _pickBudget(context, ref),
- child: const Text('Spend from'),
- ),
- // A transfer is one transaction leaving an account and one arriving in another.
- if (selection.length == 2)
- TextButton(
- key: const Key('bulk-transfer'),
- onPressed: () => controller.markAsTransfer(selection),
- child: const Text('Transfer'),
- ),
- IconButton(
- key: const Key('bulk-delete'),
- icon: const Icon(Icons.delete_outline),
- onPressed: () => controller.deleteAll(selection),
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(
+ SpendableChrome.inset,
+ 0,
+ SpendableChrome.inset,
+ SpendableSpace.tight,
+ ),
+ child: GlassPanel(
+ blur: SpendableChrome.tabBarBlur,
+ tint: colors.chrome,
+ borderRadius: BorderRadius.circular(SpendableRadius.capsule),
+ shadow: true,
+ child: SizedBox(
+ height: 50,
+ child: Row(
+ children: [
+ GestureDetector(
+ key: const Key('bulk-clear'),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => ref.read(selectionProvider.notifier).clear(),
+ child: SizedBox(
+ width: 50,
+ 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),
+ ),
+ ],
),
- ],
+ ),
),
- ),
+ ],
),
- ],
+ ),
),
);
}
- Future _pickBudget(BuildContext context, WidgetRef ref) async {
- final budgets = ref.read(budgetOptionsProvider).value ?? const [];
-
- final chosen = await showModalBottomSheet(
- context: context,
- builder: (_) => ListView(
- children: [
- for (final budget in budgets)
- ListTile(
- key: Key('budget-${budget.id}'),
- title: Text(budget.name),
- onTap: () => Navigator.of(context).pop(budget),
- ),
- ],
- ),
- );
+ Future _pick(BuildContext context, WidgetRef ref) async {
+ final chosen = await pickBudget(context, ref);
if (chosen == null) return;
@@ -286,6 +407,40 @@ class _BulkActions extends ConsumerWidget {
}
}
+class _Action extends StatelessWidget {
+ const _Action({required this.actionKey, required this.label, required this.onPressed, this.color});
+
+ 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)),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
class _Message extends StatelessWidget {
const _Message(this.text);
@@ -293,13 +448,18 @@ class _Message extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(32),
- child: Text(text, textAlign: TextAlign.center),
+ return SliverFillRemaining(
+ hasScrollBody: false,
+ child: Padding(
+ padding: const EdgeInsets.all(SpendableSpace.block),
+ child: Center(
+ child: Text(
+ text,
+ textAlign: TextAlign.center,
+ style: SpendableType.body.copyWith(color: SpendableColors.of(context).secondary),
+ ),
),
- ],
+ ),
);
}
}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 68051c03..f59542e2 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -366,6 +366,22 @@ 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:
+ name: flutter_svg
+ sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -688,6 +704,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
+ path_parsing:
+ dependency: transitive
+ description:
+ name: path_parsing
+ sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
path_provider:
dependency: transitive
description:
@@ -736,6 +760,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
plaid_flutter:
dependency: "direct main"
description:
@@ -1028,6 +1060,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.6.0"
+ vector_graphics:
+ dependency: transitive
+ description:
+ name: vector_graphics
+ sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ vector_graphics_codec:
+ dependency: transitive
+ description:
+ name: vector_graphics_codec
+ sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.13"
+ vector_graphics_compiler:
+ dependency: transitive
+ description:
+ name: vector_graphics_compiler
+ sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
vector_math:
dependency: transitive
description:
@@ -1108,6 +1164,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
yaml:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 9ae98e43..7be11e59 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -16,6 +16,8 @@ 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
riverpod_annotation: ^4.0.6
@@ -35,3 +37,5 @@ dev_dependencies:
flutter:
uses-material-design: true
+ assets:
+ - assets/icons/
diff --git a/mobile/test/auth/account_screen_test.dart b/mobile/test/auth/account_screen_test.dart
index ea51ede7..a9cb4949 100644
--- a/mobile/test/auth/account_screen_test.dart
+++ b/mobile/test/auth/account_screen_test.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:spendable/design/theme.dart';
import 'package:spendable/api/api_client.dart';
import 'package:spendable/auth/account_screen.dart';
import 'package:spendable/auth/auth_controller.dart';
@@ -30,7 +31,7 @@ Future _pump(
deviceNameProvider.overrideWith((ref) async => 'iPhone'),
apiProvider.overrideWithValue(api.build()),
],
- child: const MaterialApp(home: AccountScreen()),
+ child: MaterialApp(theme: spendableTheme(Brightness.light), home: const AccountScreen()),
),
);
diff --git a/mobile/test/auth/sign_in_screen_test.dart b/mobile/test/auth/sign_in_screen_test.dart
index 23548020..a675b6cf 100644
--- a/mobile/test/auth/sign_in_screen_test.dart
+++ b/mobile/test/auth/sign_in_screen_test.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:spendable/design/theme.dart';
import 'package:spendable/api/api_client.dart';
import 'package:spendable/auth/auth_controller.dart';
import 'package:spendable/auth/identity_tokens.dart';
@@ -23,7 +24,7 @@ Future _pump(
deviceNameProvider.overrideWith((ref) async => 'iPhone'),
apiProvider.overrideWithValue(api.build()),
],
- child: const MaterialApp(home: SignInScreen()),
+ child: MaterialApp(theme: spendableTheme(Brightness.light), home: const SignInScreen()),
),
);
diff --git a/mobile/test/banks/banks_screen_test.dart b/mobile/test/banks/banks_screen_test.dart
index 6b1dacde..30ffde9a 100644
--- a/mobile/test/banks/banks_screen_test.dart
+++ b/mobile/test/banks/banks_screen_test.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:spendable/design/theme.dart';
import 'package:spendable/api/api_client.dart';
import 'package:spendable/banks/banks_screen.dart';
import 'package:spendable/banks/pending_plaid_session.dart';
@@ -157,7 +158,7 @@ Future<(FakeApi, FakePlaidLinkFlow)> _pump(
pendingPlaidSessionProvider.overrideWithValue(session ?? FakePendingPlaidSession()),
walletProvider.overrideWithValue(wallet ?? FakeWallet(available: false)),
],
- child: const MaterialApp(home: BanksScreen()),
+ child: MaterialApp(theme: spendableTheme(Brightness.light), home: const BanksScreen()),
),
);
@@ -177,6 +178,9 @@ void main() {
expect(find.text('Checking ••••4321'), findsOneWidget);
expect(find.text(r'$120.00'), findsOneWidget);
+
+ // Opening the bank is what first asks for the budget list, so let that land.
+ await tester.pumpAndSettle();
});
// Anything other than CONNECTED means Plaid needs the user to go back through Link.
diff --git a/mobile/test/budgets/budgets_screen_test.dart b/mobile/test/budgets/budgets_screen_test.dart
index 3fe723fc..998f610c 100644
--- a/mobile/test/budgets/budgets_screen_test.dart
+++ b/mobile/test/budgets/budgets_screen_test.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:spendable/design/theme.dart';
import 'package:spendable/api/api_client.dart';
import 'package:spendable/budgets/budgets_screen.dart';
@@ -52,7 +53,7 @@ Future _pump(WidgetTester tester, {Map fetch(RequestOptions options, Stream? stream, Future? cancel) async {
+ final body = switch (options.path) {
+ '/api/budgets/summary' => {
+ 'month': '2026-08-01',
+ 'current_month': true,
+ 'spendable': '1284.55',
+ 'allocated_total': '4200.00',
+ 'spent_total': '2915.45',
+ 'credit_card_balance': '1204.66',
+ 'budgets': [
+ {
+ 'id': 'b1',
+ 'name': 'Groceries',
+ 'type': 'envelope',
+ 'balance': '142.18',
+ 'budgeted_amount': '500.00',
+ 'archived_at': null,
+ },
+ {
+ 'id': 'b2',
+ 'name': 'Dining out',
+ 'type': 'envelope',
+ 'balance': '-63.40',
+ 'budgeted_amount': '200.00',
+ 'archived_at': null,
+ },
+ {
+ 'id': 'b3',
+ 'name': 'Emergency fund',
+ 'type': 'goal',
+ 'balance': '1500.00',
+ 'budgeted_amount': '5000.00',
+ 'archived_at': null,
+ },
+ {
+ 'id': 'b4',
+ 'name': 'Amazon',
+ 'type': 'tracking',
+ 'balance': '0.00',
+ 'budgeted_amount': null,
+ 'archived_at': null,
+ },
+ ],
+ 'spent': {'b1': '-357.82', 'b2': '-263.40', 'b3': '0.00', 'b4': '-88.02'},
+ 'spent_by_month': [
+ {'month': '2026-08-01', 'spent': '-2915.45'},
+ {'month': '2026-07-01', 'spent': '-2480.19'},
+ ],
+ },
+ '/api/transactions' => [
+ {
+ 'id': 't1',
+ 'name': 'Sunset Boulevard Wine & Provisions',
+ 'amount': '-142.87',
+ 'date': '2026-08-14',
+ 'reviewed': false,
+ 'excluded': false,
+ 'note': null,
+ 'transfer_id': null,
+ 'budget_allocations':