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': [], + 'source': null, + }, + { + 'id': 't2', + 'name': 'Direct deposit', + 'amount': '2410.00', + 'date': '2026-08-14', + 'reviewed': true, + 'excluded': false, + 'note': null, + 'transfer_id': null, + 'budget_allocations': [], + 'source': null, + }, + ], + '/api/splits' => [ + { + 'id': 's1', + 'name': 'Payday', + 'archived_at': null, + 'split_lines': [ + {'id': 'sl1', 'budget_id': 'b1', 'amount': '100.00'}, + ], + }, + ], + '/api/banks' => [ + { + 'id': 'm1', + 'name': 'Chase', + 'provider': 'Plaid', + 'status': 'CONNECTED', + 'has_logo': false, + 'bank_accounts': [ + { + 'id': 'a1', + 'name': 'Checking', + 'number': '4021', + 'type': 'depository', + 'sub_type': 'checking', + 'balance': '1204.66', + 'sync': true, + 'budget_id': null, + }, + ], + }, + ], + _ => [], + }; + + return ResponseBody.fromString( + jsonEncode(body), + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +// A phone is narrower than the 800x600 the other tests run at, and the editorial type is set large +// enough that the difference is where a row runs out of room. +void main() { + for (final brightness in Brightness.values) { + testWidgets('every screen and sheet fits a phone in ${brightness.name}', (tester) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3; + tester.view.padding = const FakeViewPadding(top: 59 * 3, bottom: 34 * 3); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + apiProvider.overrideWithValue( + SpendableApi(dio: Dio(BaseOptions(baseUrl: 'https://x.test'))..httpClientAdapter = _Api()), + ), + ], + child: MaterialApp(theme: spendableTheme(brightness), home: const Shell()), + ), + ); + + await tester.pumpAndSettle(); + + for (final tab in ['tab-transactions', 'tab-splits', 'tab-banks', 'tab-budgets']) { + await tester.tap(find.byKey(Key(tab))); + await tester.pumpAndSettle(); + } + + // The month menu, hung off the large title. + await tester.tap(find.byKey(const Key('month-picker'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 700)); + await tester.pumpAndSettle(); + + // A budget sheet, and an edit of an existing row. + await tester.tap(find.byKey(const Key('new-budget'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 60)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Emergency fund')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 60)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('open-account'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('account-back'))); + await tester.pumpAndSettle(); + + // The transaction detail sheet, the filters sheet, and the bulk bar. + await tester.tap(find.byKey(const Key('tab-transactions'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('open-filters'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 60)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('transaction-t1'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(200, 60)); + await tester.pumpAndSettle(); + + await tester.longPress(find.byKey(const Key('transaction-t1'))); + await tester.pumpAndSettle(); + }); + } +} diff --git a/mobile/test/splits/splits_screen_test.dart b/mobile/test/splits/splits_screen_test.dart index ff50c14c..26e42616 100644 --- a/mobile/test/splits/splits_screen_test.dart +++ b/mobile/test/splits/splits_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/splits/splits_screen.dart'; @@ -49,7 +50,7 @@ Future _pump(WidgetTester tester, {Map _open( await tester.pumpWidget( ProviderScope( overrides: [apiProvider.overrideWithValue(api.build())], - child: const MaterialApp(home: TransactionsScreen()), + child: MaterialApp(theme: spendableTheme(Brightness.light), home: const TransactionsScreen()), ), ); @@ -92,13 +93,13 @@ void main() { expect(find.byKey(const Key('allocation-budget-0')), findsOneWidget); expect(find.byKey(const Key('allocation-amount-0')), findsNothing); - expect(find.text('Spend from'), findsOneWidget); + expect(find.text('SPEND FROM'), findsOneWidget); }); testWidgets('money coming in reads as adding to a budget', (tester) async { await _open(tester, transaction: _transaction(amount: '20.00')); - expect(find.text('Add to'), findsOneWidget); + expect(find.text('ADD TO'), findsOneWidget); }); testWidgets('a split transaction shows an amount against each budget', (tester) async { diff --git a/mobile/test/transactions/transactions_screen_test.dart b/mobile/test/transactions/transactions_screen_test.dart index 410213c6..70b7648b 100644 --- a/mobile/test/transactions/transactions_screen_test.dart +++ b/mobile/test/transactions/transactions_screen_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:spendable/design/glyph_icon.dart'; +import 'package:spendable/design/theme.dart'; import 'package:spendable/api/api_client.dart'; import 'package:spendable/transactions/transactions_screen.dart'; @@ -75,7 +77,7 @@ Future _pump(WidgetTester tester, {Map _pump(WidgetTester tester, {Map tester + .widgetList(find.byType(GlyphIcon)) + .where((icon) => icon.glyph == Glyph.checkCircleFill) + .length; + void main() { // The API hides reviewed rows unless asked; the screen is a queue and wants them shown. testWidgets('asks for reviewed rows and hides excluded ones', (tester) async { @@ -137,7 +145,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Market'), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(_reviewedMarkers(tester), 1); }); testWidgets('selecting two rows offers a transfer', (tester) async { @@ -161,7 +169,7 @@ void main() { }), ); - await tester.tap(find.byKey(const Key('select-txn_1'))); + await tester.longPress(find.byKey(const Key('transaction-txn_1'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('bulk-transfer')), findsNothing); @@ -194,7 +202,7 @@ void main() { }), ); - await tester.tap(find.byKey(const Key('select-txn_1'))); + await tester.longPress(find.byKey(const Key('transaction-txn_1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle(); @@ -225,7 +233,7 @@ void main() { }), ); - await tester.tap(find.byKey(const Key('select-txn_1'))); + await tester.longPress(find.byKey(const Key('transaction-txn_1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle(); @@ -234,7 +242,7 @@ void main() { expect(api.requests.last.data, containsPair('reviewed', true)); expect(find.byKey(const Key('bulk-review')), findsNothing); - expect(find.byIcon(Icons.check_circle), findsNWidgets(2)); + expect(_reviewedMarkers(tester), 2); }); // A bulk delete is applied per transaction, so the ones that failed have to stay on screen. @@ -258,7 +266,7 @@ void main() { }), ); - await tester.tap(find.byKey(const Key('select-txn_1'))); + await tester.longPress(find.byKey(const Key('transaction-txn_1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('select-txn_2'))); await tester.pumpAndSettle();