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..abb159d6 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,122 @@ 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();