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
4 changes: 4 additions & 0 deletions mobile/assets/brand/mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions mobile/assets/brand/wordmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
149 changes: 113 additions & 36 deletions mobile/lib/auth/sign_in_screen.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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';
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});

Expand All @@ -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),
),
),
),
),
],
);
}
}
64 changes: 64 additions & 0 deletions mobile/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -37,4 +38,5 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- assets/brand/
- assets/icons/
7 changes: 7 additions & 0 deletions mobile/test/auth/sign_in_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading