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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/assets/icons/bank-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/bank.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/caret-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/caret-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/caret-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/check-circle-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/copy-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/copy.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/credit-card-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/credit-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/funnel.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/minus-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/money-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/money.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/user.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/wallet.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mobile/assets/icons/x.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions mobile/docs/design.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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.
Expand Down
140 changes: 94 additions & 46 deletions mobile/lib/auth/account_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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())),
],
},
);
}

Expand All @@ -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,
],
),
);
}
Expand All @@ -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),
),
),
],
),
);
}
}
Loading
Loading