From 5a647cebe7d57973272b77cef095b16f71a277a3 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 17:59:24 -0600 Subject: [PATCH 1/2] Open on the brand, and say what the app promises The sign-in screen led with a text wordmark and a line about spending. It now opens on the app icon itself, the real lockup, and the promise that decides whether someone signs up: open source, no ads, data never sold. The privacy policy sits at the foot behind a hairline, so the claim is checkable. Co-Authored-By: Claude Opus 5 --- mobile/assets/brand/mark.svg | 4 + mobile/assets/brand/wordmark.svg | 13 ++ mobile/lib/auth/sign_in_screen.dart | 152 +++++++++++++++++----- mobile/pubspec.lock | 64 +++++++++ mobile/pubspec.yaml | 2 + mobile/test/auth/sign_in_screen_test.dart | 7 + 6 files changed, 206 insertions(+), 36 deletions(-) create mode 100644 mobile/assets/brand/mark.svg create mode 100644 mobile/assets/brand/wordmark.svg diff --git a/mobile/assets/brand/mark.svg b/mobile/assets/brand/mark.svg new file mode 100644 index 00000000..a07edd9a --- /dev/null +++ b/mobile/assets/brand/mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/assets/brand/wordmark.svg b/mobile/assets/brand/wordmark.svg new file mode 100644 index 00000000..c9c0a4b6 --- /dev/null +++ b/mobile/assets/brand/wordmark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/mobile/lib/auth/sign_in_screen.dart b/mobile/lib/auth/sign_in_screen.dart index b17b42b1..59ff0c39 100644 --- a/mobile/lib/auth/sign_in_screen.dart +++ b/mobile/lib/auth/sign_in_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../design/primary_button.dart'; import '../design/tokens.dart'; @@ -7,6 +9,8 @@ import '../design/typography.dart'; import 'auth_controller.dart'; import 'identity_tokens.dart'; +final _privacyPolicy = Uri.parse('https://spendable.money/privacy-policy'); + class SignInScreen extends ConsumerWidget { const SignInScreen({super.key}); @@ -18,49 +22,125 @@ class SignInScreen extends ConsumerWidget { return Scaffold( backgroundColor: colors.ground, body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: SpendableSpace.block), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: SpendableSpace.block), + child: Column( + children: [ + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: _LogoTile()), + const SizedBox(height: SpendableSpace.block), + Center( + child: SvgPicture.asset( + 'assets/brand/wordmark.svg', + height: 26, + colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn), + ), + ), + const SizedBox(height: SpendableSpace.step), + Text( + 'Open source. No ads. Your financial data is never sold.', + textAlign: TextAlign.center, + style: SpendableType.body.copyWith(color: colors.secondary), + ), + const SizedBox(height: 40), + for (final provider in AuthProvider.values) ...[ + 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: SpendableSpace.step), + ], + if (auth.hasError) + Text( + '${auth.error}', + key: const Key('sign-in-error'), + textAlign: TextAlign.center, + style: SpendableType.subhead.copyWith(color: colors.negative), + ), + ], + ), + ), + ), + ), + const _PrivacyNote(), + ], + ), + ), + ), + ); + } +} + +/// The app icon itself, so the screen opens on the thing that was tapped to reach it. Its ground is +/// the mark's own black in either theme, the way an icon does not restyle for dark mode. +class _LogoTile extends StatelessWidget { + const _LogoTile(); + + @override + Widget build(BuildContext context) { + return Container( + width: 76, + height: 76, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF121110), + borderRadius: BorderRadius.circular(20), + ), + child: SvgPicture.asset( + 'assets/brand/mark.svg', + width: 42, + height: 42, + colorFilter: const ColorFilter.mode(Color(0xFFFFFEFD), BlendMode.srcIn), + ), + ); + } +} + +class _PrivacyNote extends StatelessWidget { + const _PrivacyNote(); + + @override + Widget build(BuildContext context) { + final colors = SpendableColors.of(context); + + return Column( + children: [ + Container(height: 1, color: colors.separator), + GestureDetector( + key: const Key('privacy-policy'), + behavior: HitTestBehavior.opaque, + onTap: () => launchUrl(_privacyPolicy, mode: LaunchMode.externalApplication), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: SpendableSpace.gutter), + child: Text.rich( + TextSpan( children: [ - 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 TextSpan(text: 'Read the '), + TextSpan( + text: 'privacy policy', + style: TextStyle(color: colors.accent, fontWeight: FontWeight.w500), ), - const SizedBox(height: 48), - for (final provider in AuthProvider.values) ...[ - 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: SpendableSpace.step), - ], - if (auth.hasError) - Text( - '${auth.error}', - key: const Key('sign-in-error'), - textAlign: TextAlign.center, - style: SpendableType.subhead.copyWith(color: colors.negative), - ), ], ), + style: SpendableType.subhead.copyWith(color: colors.tertiary), ), ), ), - ), + ], ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 00931839..2d1278fb 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1044,6 +1044,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1047ba2e..ca02f922 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: plaid_flutter: ^5.2.1 riverpod_annotation: ^4.0.6 sign_in_with_apple: ^8.1.0 + url_launcher: ^6.3.1 # Generated from priv/static/openapi.json - see tool/generate_api.sh. spendable_api: @@ -37,4 +38,5 @@ dev_dependencies: flutter: uses-material-design: true assets: + - assets/brand/ - assets/icons/ diff --git a/mobile/test/auth/sign_in_screen_test.dart b/mobile/test/auth/sign_in_screen_test.dart index a675b6cf..b1fd449e 100644 --- a/mobile/test/auth/sign_in_screen_test.dart +++ b/mobile/test/auth/sign_in_screen_test.dart @@ -39,6 +39,13 @@ void main() { expect(find.byKey(const Key('sign-in-google')), findsOneWidget); }); + testWidgets('says what the app promises before asking to sign in', (tester) async { + await _pump(tester, identities: FakeIdentityTokens(), api: FakeApi({})); + + expect(find.text('Open source. No ads. Your financial data is never sold.'), findsOneWidget); + expect(find.byKey(const Key('privacy-policy')), findsOneWidget); + }); + testWidgets('tapping a provider signs in with it', (tester) async { final identities = FakeIdentityTokens(token: 'id-token'); final storage = FakeTokenStorage(); From c278f60b9ef8aebf9404608698d14baeda2cf4b6 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 18:03:14 -0600 Subject: [PATCH 2/2] Format at the project's line length Co-Authored-By: Claude Opus 5 --- mobile/lib/auth/sign_in_screen.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mobile/lib/auth/sign_in_screen.dart b/mobile/lib/auth/sign_in_screen.dart index 59ff0c39..abb159d6 100644 --- a/mobile/lib/auth/sign_in_screen.dart +++ b/mobile/lib/auth/sign_in_screen.dart @@ -96,10 +96,7 @@ class _LogoTile extends StatelessWidget { width: 76, height: 76, alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0xFF121110), - borderRadius: BorderRadius.circular(20), - ), + decoration: BoxDecoration(color: const Color(0xFF121110), borderRadius: BorderRadius.circular(20)), child: SvgPicture.asset( 'assets/brand/mark.svg', width: 42,