diff --git a/app/assets/images/onboarding/1.png b/app/assets/images/onboarding/1.png new file mode 100644 index 000000000..5483ad9d4 Binary files /dev/null and b/app/assets/images/onboarding/1.png differ diff --git a/app/assets/images/onboarding/2.png b/app/assets/images/onboarding/2.png new file mode 100644 index 000000000..d91a4f6ac Binary files /dev/null and b/app/assets/images/onboarding/2.png differ diff --git a/app/assets/images/onboarding/3.png b/app/assets/images/onboarding/3.png new file mode 100644 index 000000000..734b3be6a Binary files /dev/null and b/app/assets/images/onboarding/3.png differ diff --git a/app/assets/images/onboarding/4.png b/app/assets/images/onboarding/4.png new file mode 100644 index 000000000..be48658e5 Binary files /dev/null and b/app/assets/images/onboarding/4.png differ diff --git a/app/assets/images/onboarding/5.png b/app/assets/images/onboarding/5.png new file mode 100644 index 000000000..296932ac7 Binary files /dev/null and b/app/assets/images/onboarding/5.png differ diff --git a/app/assets/images/onboarding/svg/2.svg b/app/assets/images/onboarding/svg/2.svg new file mode 100644 index 000000000..347eb1fac --- /dev/null +++ b/app/assets/images/onboarding/svg/2.svg @@ -0,0 +1,594 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/onboarding/svg/3.svg b/app/assets/images/onboarding/svg/3.svg new file mode 100644 index 000000000..e8fbf60f0 --- /dev/null +++ b/app/assets/images/onboarding/svg/3.svg @@ -0,0 +1,1332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/onboarding/svg/4.svg b/app/assets/images/onboarding/svg/4.svg new file mode 100644 index 000000000..ed7dc6a3e --- /dev/null +++ b/app/assets/images/onboarding/svg/4.svg @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + diff --git a/app/assets/images/onboarding/svg/5.svg b/app/assets/images/onboarding/svg/5.svg new file mode 100644 index 000000000..52727758d --- /dev/null +++ b/app/assets/images/onboarding/svg/5.svg @@ -0,0 +1,725 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/integration_test/onboarding_test.dart b/app/integration_test/onboarding_test.dart new file mode 100644 index 000000000..655fb7bfc --- /dev/null +++ b/app/integration_test/onboarding_test.dart @@ -0,0 +1,52 @@ +import 'package:app/common/module.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.onlyPumps; + + group('integration tests for the onboarding', () { + testWidgets('Test that pages are changing', (tester) async { + final appRouter = AppRouter(); + await tester.pumpWidget(MaterialApp.router( + debugShowCheckedModeBanner: false, + routeInformationParser: appRouter.defaultRouteParser(), + routerDelegate: appRouter.delegate( + initialDeepLink: 'onboarding', + ), + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [Locale('en', '')], + )); + + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(Scaffold).first); + expect(find.text(context.l10n.onboarding_1_header), findsOneWidget); + + // change page + await tester.tap(find.byKey(ValueKey('nextButton'))); + await tester.pumpAndSettle(); + expect(find.text(context.l10n.onboarding_2_header), findsOneWidget); + // change page + await tester.tap(find.byKey(ValueKey('nextButton'))); + await tester.pumpAndSettle(); + expect(find.text(context.l10n.onboarding_3_header), findsOneWidget); + + await tester.tap(find.byKey(ValueKey('nextButton'))); + await tester.pumpAndSettle(); + expect(find.text(context.l10n.onboarding_4_header), findsOneWidget); + + await tester.tap(find.byKey(ValueKey('nextButton'))); + await tester.pumpAndSettle(); + expect(find.text(context.l10n.onboarding_5_header), findsOneWidget); + }); + }); +} diff --git a/app/integration_test/settings_test.dart b/app/integration_test/settings_test.dart index 02b5e11ef..8954b95e7 100644 --- a/app/integration_test/settings_test.dart +++ b/app/integration_test/settings_test.dart @@ -47,6 +47,15 @@ void main() { await tester.tap(find.text(context.l10n.action_cancel)); await tester.pumpAndSettle(); + // test onboarding button + await tester.tap(find.text(context.l10n.settings_page_onboarding)); + await tester.pumpAndSettle(); + + expect(find.text(context.l10n.onboarding_1_header), findsOneWidget); + + await context.router.root.pop(); + await tester.pumpAndSettle(); + // test about us await tester.tap(find.text(context.l10n.settings_page_about_us)); await tester.pumpAndSettle(); diff --git a/app/lib/common/routing/router.dart b/app/lib/common/routing/router.dart index 93bb4db63..d6abfb4d1 100644 --- a/app/lib/common/routing/router.dart +++ b/app/lib/common/routing/router.dart @@ -1,6 +1,7 @@ import '../../common/module.dart'; import '../../faq/module.dart'; import '../../login/module.dart'; +import '../../onboarding/module.dart'; import '../../report/module.dart'; import '../../search/module.dart'; import '../../settings/module.dart'; @@ -12,6 +13,7 @@ part 'router.gr.dart'; replaceInRouteName: 'Page,Route', routes: [ loginRoutes, + onboardingRoutes, AutoRoute( path: 'main', page: MainPage, diff --git a/app/lib/common/widgets/app.dart b/app/lib/common/widgets/app.dart index d1162e909..a5526fda1 100644 --- a/app/lib/common/widgets/app.dart +++ b/app/lib/common/widgets/app.dart @@ -21,7 +21,7 @@ class PharMeApp extends StatelessWidget { debugShowCheckedModeBanner: false, routeInformationParser: _appRouter.defaultRouteParser(), routerDelegate: _appRouter.delegate( - initialDeepLink: _isLoggedIn ? 'main' : 'login', + initialDeepLink: _isLoggedIn ? 'main' : 'onboarding', ), theme: PharMeTheme.light, localizationsDelegates: [ diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 68c5ab4d3..2fb807fda 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -114,10 +114,27 @@ "nav_more": "More", "tab_more": "More", + "onboarding_get_started": "Get started", + "onboarding_next": "Next", + "onboarding_prev": "Back", + "onboarding_1_header": "Welcome to PharMe", + "onboarding_1_text": "Your genome has more influence on your life than you might think!\nMore than 90% of people are vulnerable to unintended drug reactions.\n\nUse PharMe to find out about yours.", + "onboarding_2_header": "One size does not fit all", + "onboarding_2_text": "Your body reacts to drugs uniquely.\n\nDrugs that are effective for a majority of people can be toxic to you.", + "onboarding_3_header": "Genome power unlocked to improve human health", + "onboarding_3_text": "PharMe warns about unintended drug responses according to your genome.\nThis enables you to avoid drugs that are ineffective or have side-effects.", + "onboarding_3_disclaimer": "We DO NOT recommend drugs for specific symptoms or illnesses!", + "onboarding_4_header": "Tailored to your genome", + "onboarding_4_text": "For PharMe to work, you need to get your genetics tested at a lab. You don't need an account to use PharMe: You can just sign in to the lab's website through our app.", + "onboarding_4_button": "Find out more about gene tests here.", + "onboarding_5_header": "We care about your data protection", + "onboarding_5_text": "After signing in to the lab, your genetic data is sent straight onto your phone.\n\nOur servers know nothing about you, neither your identity nor your genetics.", + "settings_page_account_settings": "Account Settings", "settings_page_delete_data": "Delete App Data", "settings_page_delete_data_text": "Are you sure that you want to delete all app data? This also includes your genomic data.", "settings_page_more": "More", + "settings_page_onboarding": "Onboarding", "settings_page_about_us": "About us", "settings_page_about_us_text": "PharMe was created as a bachelors project at Hasso-Plattner-Institut (HPI) Potsdam. The aim of the project is to provide a proof of concept for digital health apps that use pharmacogenomic information for health improvements. We collaborate with the Digital Health chair at HPI and health professionals from Mount Sinai hospital in New York.", "settings_page_privacy_policy": "Privacy policy", diff --git a/app/lib/onboarding/module.dart b/app/lib/onboarding/module.dart new file mode 100644 index 000000000..e01f2332a --- /dev/null +++ b/app/lib/onboarding/module.dart @@ -0,0 +1,10 @@ +import '../common/module.dart'; +import 'pages/onboarding.dart'; + +export 'pages/onboarding.dart'; + +const onboardingRoutes = AutoRoute( + path: 'onboarding', + name: 'OnboardingRouter', + page: OnboardingPage, +); diff --git a/app/lib/onboarding/pages/onboarding.dart b/app/lib/onboarding/pages/onboarding.dart new file mode 100644 index 000000000..761726b9a --- /dev/null +++ b/app/lib/onboarding/pages/onboarding.dart @@ -0,0 +1,325 @@ +import 'package:url_launcher/url_launcher.dart'; + +import '../../../common/models/metadata.dart'; +import '../../../common/module.dart' hide MetaData; + +class OnboardingPage extends HookWidget { + final _pages = [ + OnboardingSubPage( + illustrationPath: 'assets/images/onboarding/1.png', + getHeader: (context) => context.l10n.onboarding_1_header, + getText: (context) => context.l10n.onboarding_1_text, + color: Color(0xFFFF7E41), + ), + OnboardingSubPage( + illustrationPath: 'assets/images/onboarding/2.png', + getHeader: (context) => context.l10n.onboarding_2_header, + getText: (context) => context.l10n.onboarding_2_text, + color: Color(0xCCCC0700), + ), + OnboardingSubPage( + illustrationPath: 'assets/images/onboarding/3.png', + getHeader: (context) => context.l10n.onboarding_3_header, + getText: (context) => context.l10n.onboarding_3_text, + color: Color(0xCC359600), + child: BottomCard( + icon: Icon(Icons.warning_rounded, size: 32), + getText: (context) => context.l10n.onboarding_3_disclaimer, + ), + ), + OnboardingSubPage( + illustrationPath: 'assets/images/onboarding/4.png', + getHeader: (context) => context.l10n.onboarding_4_header, + getText: (context) => context.l10n.onboarding_4_text, + color: Color(0xFF00B9FA), + child: BottomCard( + getText: (context) => context.l10n.onboarding_4_button, + onClick: () => launchUrl( + Uri.parse( + 'https://www.cdc.gov/genomics/gtesting/genetic_testing.htm', + ), + ), + ), + ), + OnboardingSubPage( + illustrationPath: 'assets/images/onboarding/5.png', + getHeader: (context) => context.l10n.onboarding_5_header, + getText: (context) => context.l10n.onboarding_5_text, + color: Color(0xFF0A64BC), + ), + ]; + + final _isLoggedIn = MetaData.instance.isLoggedIn ?? false; + + @override + Widget build(BuildContext context) { + final colors = _pages.map((page) => page.color); + final tweenSequenceItems = []; + for (var tweenIndex = 0; tweenIndex < colors.length - 1; tweenIndex++) { + tweenSequenceItems.add( + TweenSequenceItem( + weight: 1, + tween: ColorTween( + begin: colors.elementAt(tweenIndex), + end: colors.elementAt(tweenIndex + 1), + ), + ), + ); + } + final background = TweenSequence(tweenSequenceItems); + + final pageController = usePageController(initialPage: 0); + final currentPage = useState(0); + + return Scaffold( + body: AnimatedBuilder( + animation: pageController, + builder: (context, child) { + final color = pageController.hasClients + ? pageController.page! / (_pages.length - 1) + : .0; + + return DecoratedBox( + decoration: BoxDecoration( + color: background.evaluate(AlwaysStoppedAnimation(color)), + ), + child: child, + ); + }, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + bottom: 96, + child: PageView( + controller: pageController, + onPageChanged: (newPage) => currentPage.value = newPage, + children: _pages, + ), + ), + Positioned( + bottom: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _buildPageIndicator(context, currentPage.value), + ), + ), + Positioned( + bottom: 16, + right: 16, + child: _buildNextButton( + context, + pageController, + currentPage.value == _pages.length - 1, + ), + ), + Positioned( + bottom: 16, + left: 16, + child: _buildPrevButton( + context, + pageController, + currentPage.value == 0, + ), + ) + ], + ), + ), + ); + } + + List _buildPageIndicator(BuildContext context, int currentPage) { + final list = []; + for (var i = 0; i < _pages.length; ++i) { + list.add(_indicator(context, i == currentPage)); + } + return list; + } + + Widget _indicator(BuildContext context, bool isActive) { + return AnimatedContainer( + duration: Duration(milliseconds: 150), + margin: EdgeInsets.symmetric(horizontal: 8), + height: 8, + width: isActive ? 24 : 16, + decoration: BoxDecoration( + color: isActive ? Colors.white : PharMeTheme.onSurfaceColor, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ); + } + + Widget _buildNextButton( + BuildContext context, + PageController pageController, + bool isLastPage, + ) { + return TextButton( + key: Key('nextButton'), + onPressed: () { + if (isLastPage) { + _isLoggedIn + ? context.router.pop() + : context.router.replace(LoginRouter()); + } else { + pageController.nextPage( + duration: Duration(milliseconds: 500), + curve: Curves.ease, + ); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isLastPage + ? context.l10n.onboarding_get_started + : context.l10n.onboarding_next, + style: PharMeTheme.textTheme.headlineSmall! + .copyWith(color: Colors.white), + ), + SizedBox(width: 8), + Icon( + Icons.arrow_forward_rounded, + color: Colors.white, + size: 32, + ), + ], + ), + ); + } + + Widget _buildPrevButton( + BuildContext context, + PageController pageController, + bool isFirstPage, + ) { + if (!isFirstPage) { + return TextButton( + key: Key('prevButton'), + onPressed: () { + pageController.previousPage( + duration: Duration(milliseconds: 500), + curve: Curves.ease, + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_back_rounded, + color: Colors.white, + size: 32, + ), + SizedBox(width: 8), + Text( + context.l10n.onboarding_prev, + style: PharMeTheme.textTheme.headlineSmall! + .copyWith(color: Colors.white), + ), + ], + ), + ); + } else { + return SizedBox.shrink(); + } + } +} + +class OnboardingSubPage extends StatelessWidget { + const OnboardingSubPage({ + required this.illustrationPath, + this.secondImagePath, + required this.getHeader, + required this.getText, + required this.color, + this.child, + }); + + final String illustrationPath; + final String? secondImagePath; + final String Function(BuildContext) getHeader; + final String Function(BuildContext) getText; + final Color color; + final Widget? child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(32, 16, 32, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: PharMeTheme.mediumSpace), + Center( + child: FractionallySizedBox( + alignment: Alignment.topCenter, + widthFactor: 0.75, + child: Image.asset( + illustrationPath, + width: 320, + ), + ), + ), + SizedBox(height: PharMeTheme.mediumSpace), + Column(children: [ + AutoSizeText( + getHeader(context), + style: PharMeTheme.textTheme.headlineLarge!.copyWith( + color: Colors.white, + ), + maxLines: 2, + ), + SizedBox(height: PharMeTheme.mediumSpace), + Text( + getText(context), + style: PharMeTheme.textTheme.bodyLarge!.copyWith( + color: Colors.white, + ), + ), + if (child != null) ...[SizedBox(height: PharMeTheme.mediumSpace), child!], + ]), + // Empty widget for spaceBetween in this column to work properly + Container(), + ], + ), + ); + } +} + +class BottomCard extends StatelessWidget { + const BottomCard({this.icon, required this.getText, this.onClick}); + + final Icon? icon; + final String Function(BuildContext) getText; + final GestureTapCallback? onClick; + + @override + Widget build(BuildContext context) { + final widget = Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row(children: [ + if (icon != null) ...[icon!, SizedBox(width: 4)], + Expanded( + child: Text( + getText(context), + style: PharMeTheme.textTheme.bodyMedium, + textAlign: (icon != null) ? TextAlign.start : TextAlign.center, + ), + ), + ]), + ), + ); + + if (onClick != null) return InkWell(onTap: onClick, child: widget); + + return widget; + } +} diff --git a/app/lib/settings/pages/settings.dart b/app/lib/settings/pages/settings.dart index 3b1ddef9b..7cbbef48c 100644 --- a/app/lib/settings/pages/settings.dart +++ b/app/lib/settings/pages/settings.dart @@ -28,6 +28,11 @@ class SettingsPage extends StatelessWidget { style: PharMeTheme.textTheme.bodyLarge, ), ), + ListTile( + title: Text(context.l10n.settings_page_onboarding), + trailing: Icon(Icons.chevron_right_rounded), + onTap: () => context.router.push(OnboardingRouter()), + ), ListTile( title: Text(context.l10n.settings_page_about_us), trailing: Icon(Icons.chevron_right_rounded), diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 24401d00c..3681435af 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -62,6 +62,7 @@ flutter: assets: - assets/images/ + - assets/images/onboarding/ flutter_icons: android: 'launcher_icon'