diff --git a/lib/app/app_layout.dart b/lib/app/app_layout.dart index b301a3a..595e756 100644 --- a/lib/app/app_layout.dart +++ b/lib/app/app_layout.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:routefly/routefly.dart'; +import 'package:zup_app/core/cache.dart'; import 'package:zup_app/core/injections.dart'; import 'package:zup_app/widgets/app_bottom_navigation_bar.dart'; +import 'package:zup_app/widgets/app_cookies_consent_widget.dart'; import 'package:zup_app/widgets/app_footer.dart'; import 'package:zup_app/widgets/app_header/app_header.dart'; import 'package:zup_core/mixins/device_info_mixin.dart'; @@ -17,11 +19,38 @@ class _AppPageState extends State with DeviceInfoMixin { bool get shouldShowBottomNavigationBar => isTabletSize(context); final double appBarHeight = 85; + final cache = inject(); final ScrollController appScrollController = inject( instanceName: InjectInstanceNames.appScrollController, ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + late OverlayEntry overlayEntry; + + overlayEntry = OverlayEntry( + builder: (context) { + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(20), + child: SelectionArea( + child: AppCookieConsentWidget( + onAccept: () => overlayEntry.remove(), + ), + ), + ), + ); + }, + ); + + if (cache.getCookiesConsentStatus() == null) Overlay.of(context).insert(overlayEntry); + }); + } + @override Widget build(BuildContext context) { return SelectionArea( diff --git a/lib/core/cache.dart b/lib/core/cache.dart index dce6b5f..9b7dce5 100644 --- a/lib/core/cache.dart +++ b/lib/core/cache.dart @@ -8,6 +8,7 @@ enum CacheKey { hidingClosedPositions, depositSettings, poolSearchSettings, + areCookiesConsented, isTestnetMode; String get key => name; @@ -50,6 +51,14 @@ class Cache { await _cache.setString(CacheKey.poolSearchSettings.key, jsonEncode(settings.toJson())); } + Future saveCookiesConsentStatus({required bool status}) async { + await _cache.setBool(CacheKey.areCookiesConsented.key, status); + } + + bool? getCookiesConsentStatus() { + return _cache.getBool(CacheKey.areCookiesConsented.key); + } + PoolSearchSettingsDto getPoolSearchSettings() { final cache = _cache.getString(CacheKey.poolSearchSettings.key) ?? "{}"; diff --git a/lib/l10n/en.arb b/lib/l10n/en.arb index f4af791..65b8508 100644 --- a/lib/l10n/en.arb +++ b/lib/l10n/en.arb @@ -2,10 +2,12 @@ "@@locale": "en", "twentyFourHours": "24h", "appFooterTermsOfUse": "Terms of Use", - "appFooterPrivacyPolicy": "Privacy Policy", + "privacyPolicy": "Privacy Policy", "appFooterContactUs": "Contact Us", + "appCookiesConsentWidgetDescription": "We use cookies to ensure that we give you the best experience on our app. By continuing to use Zup Protocol, you agree to our", "appFooterDocs": "Docs", "appFooterFAQ": "FAQ", + "understood": "Understood", "depositPageShowingOnlyPoolsWithMoreThan": "Showing only liquidity pools with more than {minLiquidity}.", "@depositPageShowingOnlyPoolsWithMoreThan": { "placeholders": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index f64d293..2479cca 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -105,11 +105,11 @@ abstract class S { /// **'Terms of Use'** String get appFooterTermsOfUse; - /// No description provided for @appFooterPrivacyPolicy. + /// No description provided for @privacyPolicy. /// /// In en, this message translates to: /// **'Privacy Policy'** - String get appFooterPrivacyPolicy; + String get privacyPolicy; /// No description provided for @appFooterContactUs. /// @@ -117,6 +117,12 @@ abstract class S { /// **'Contact Us'** String get appFooterContactUs; + /// No description provided for @appCookiesConsentWidgetDescription. + /// + /// In en, this message translates to: + /// **'We use cookies to ensure that we give you the best experience on our app. By continuing to use Zup Protocol, you agree to our'** + String get appCookiesConsentWidgetDescription; + /// No description provided for @appFooterDocs. /// /// In en, this message translates to: @@ -129,6 +135,12 @@ abstract class S { /// **'FAQ'** String get appFooterFAQ; + /// No description provided for @understood. + /// + /// In en, this message translates to: + /// **'Understood'** + String get understood; + /// No description provided for @depositPageShowingOnlyPoolsWithMoreThan. /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index b01c7b3..0a15544 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -15,17 +15,24 @@ class SEn extends S { String get appFooterTermsOfUse => 'Terms of Use'; @override - String get appFooterPrivacyPolicy => 'Privacy Policy'; + String get privacyPolicy => 'Privacy Policy'; @override String get appFooterContactUs => 'Contact Us'; + @override + String get appCookiesConsentWidgetDescription => + 'We use cookies to ensure that we give you the best experience on our app. By continuing to use Zup Protocol, you agree to our'; + @override String get appFooterDocs => 'Docs'; @override String get appFooterFAQ => 'FAQ'; + @override + String get understood => 'Understood'; + @override String depositPageShowingOnlyPoolsWithMoreThan( {required String minLiquidity}) { diff --git a/lib/widgets/app_cookies_consent_widget.dart b/lib/widgets/app_cookies_consent_widget.dart new file mode 100644 index 0000000..8a35946 --- /dev/null +++ b/lib/widgets/app_cookies_consent_widget.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:zup_app/core/cache.dart'; +import 'package:zup_app/core/injections.dart'; +import 'package:zup_app/core/zup_links.dart'; +import 'package:zup_app/l10n/gen/app_localizations.dart'; +import 'package:zup_ui_kit/zup_ui_kit.dart'; + +class AppCookieConsentWidget extends StatelessWidget { + AppCookieConsentWidget({super.key, required this.onAccept}); + + final void Function() onAccept; + + final zupLinks = inject(); + final cache = inject(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: ZupColors.white, + border: Border.all(color: ZupColors.gray5), + borderRadius: BorderRadius.circular(12), + ), + width: 300, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + TextSpan(children: [ + TextSpan( + text: S.of(context).appCookiesConsentWidgetDescription, + style: const TextStyle(color: ZupColors.gray, fontSize: 14), + ), + const TextSpan(text: " "), + WidgetSpan( + child: SizedBox( + height: 17, + child: TextButton( + key: const Key("privacy-policy-button"), + onPressed: () { + zupLinks.launchPrivacyPolicy(); + }, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + minimumSize: WidgetStateProperty.all(Size.zero), + splashFactory: NoSplash.splashFactory, + backgroundColor: WidgetStateProperty.all(Colors.transparent), + overlayColor: WidgetStateProperty.all(Colors.transparent), + padding: WidgetStateProperty.all(EdgeInsets.zero), + ), + child: Text( + S.of(context).privacyPolicy, + style: const TextStyle(decoration: TextDecoration.underline, fontSize: 14), + ), + ), + ), + style: const TextStyle(color: ZupColors.black, fontSize: 14), + ), + ]), + ), + const SizedBox(height: 20), + ZupPrimaryButton( + key: const Key("accept-cookies-button"), + height: 40, + title: S.of(context).understood, + hoverElevation: 0, + backgroundColor: ZupColors.brand6, + foregroundColor: ZupColors.brand, + onPressed: () { + onAccept(); + cache.saveCookiesConsentStatus(status: true); + }, + alignCenter: true, + width: double.maxFinite, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/app_footer.dart b/lib/widgets/app_footer.dart index e7ebc29..13d0f06 100644 --- a/lib/widgets/app_footer.dart +++ b/lib/widgets/app_footer.dart @@ -32,7 +32,7 @@ enum _AppFooterButton { _AppFooterButton.twitter => "", _AppFooterButton.telegram => "", _AppFooterButton.termsOfUse => S.of(context).appFooterTermsOfUse, - _AppFooterButton.privacyPolicy => S.of(context).appFooterPrivacyPolicy, + _AppFooterButton.privacyPolicy => S.of(context).privacyPolicy, _AppFooterButton.docs => S.of(context).appFooterDocs, _AppFooterButton.faq => S.of(context).appFooterFAQ, _AppFooterButton.contactUs => S.of(context).appFooterContactUs diff --git a/test/app/app_layout_test.dart b/test/app/app_layout_test.dart index 6b150d9..373fcf2 100644 --- a/test/app/app_layout_test.dart +++ b/test/app/app_layout_test.dart @@ -7,6 +7,7 @@ import 'package:routefly/routefly.dart'; import 'package:web3kit/web3kit.dart'; import 'package:zup_app/app/app_cubit/app_cubit.dart'; import 'package:zup_app/app/app_layout.dart'; +import 'package:zup_app/core/cache.dart'; import 'package:zup_app/core/enums/networks.dart'; import 'package:zup_app/core/enums/zup_navigator_paths.dart'; import 'package:zup_app/core/injections.dart'; @@ -20,15 +21,18 @@ import '../mocks.dart'; void main() { late AppCubit appCubit; + late Cache cache; setUp(() async { await Web3Kit.initializeForTest(); appCubit = AppCubitMock(); + cache = CacheMock(); inject.registerFactory(() => ZupLinksMock()); inject.registerFactory(() => ZupNavigator()); inject.registerFactory(() => appCubit); + inject.registerFactory(() => cache); inject.registerFactory( () => ScrollController(), instanceName: InjectInstanceNames.appScrollController, @@ -38,6 +42,7 @@ void main() { when(() => appCubit.state).thenReturn(const AppState.standard()); when(() => appCubit.isTestnetMode).thenReturn(false); when(() => appCubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => cache.getCookiesConsentStatus()).thenReturn(true); }); Future goldenBuilder({bool isMobile = false}) async => await goldenDeviceBuilder( @@ -84,4 +89,22 @@ void main() { await tester.drag(find.byKey(const Key("screen")).first, const Offset(0, -500)); }); + + zGoldenTest( + "When initializing, and the cookies consent is not saved in the cache, it should display a cookie consent overlay", + goldenFileName: "app_layout_cookie_consent_null", + (tester) async { + when(() => cache.getCookiesConsentStatus()).thenReturn(null); + await tester.pumpDeviceBuilder(await goldenBuilder(), wrapper: GoldenConfig.localizationsWrapper()); + }, + ); + + zGoldenTest( + "When initializing, and a cookies consent is saved in the cache (either true or false), it should not display a cookie consent overlay", + goldenFileName: "app_layout_cookie_consent_not_null", + (tester) async { + when(() => cache.getCookiesConsentStatus()).thenReturn(false); + await tester.pumpDeviceBuilder(await goldenBuilder(), wrapper: GoldenConfig.localizationsWrapper()); + }, + ); } diff --git a/test/app/goldens/app_layout_cookie_consent_not_null.png b/test/app/goldens/app_layout_cookie_consent_not_null.png new file mode 100644 index 0000000..bfac424 Binary files /dev/null and b/test/app/goldens/app_layout_cookie_consent_not_null.png differ diff --git a/test/app/goldens/app_layout_cookie_consent_null.png b/test/app/goldens/app_layout_cookie_consent_null.png new file mode 100644 index 0000000..226a708 Binary files /dev/null and b/test/app/goldens/app_layout_cookie_consent_null.png differ diff --git a/test/core/cache_test.dart b/test/core/cache_test.dart index 5024a27..abb79ef 100644 --- a/test/core/cache_test.dart +++ b/test/core/cache_test.dart @@ -144,4 +144,22 @@ void main() { expect(result, result); verify(() => sharedPreferencesWithCache.getString(CacheKey.poolSearchSettings.key)).called(1); }); + + test("when calling 'saveCookiesConsentStatus' it should save under the correct key", () { + when(() => sharedPreferencesWithCache.setBool(any(), any())).thenAnswer((_) async => true); + + const status = true; + sut.saveCookiesConsentStatus(status: status); + + verify(() => sharedPreferencesWithCache.setBool(CacheKey.areCookiesConsented.key, status)).called(1); + }); + + test("when calling 'getCookiesConsentStatus' it should get under the correct key", () { + when(() => sharedPreferencesWithCache.getBool(any())).thenReturn(true); + + final result = sut.getCookiesConsentStatus(); + + expect(result, true); + verify(() => sharedPreferencesWithCache.getBool(CacheKey.areCookiesConsented.key)).called(1); + }); } diff --git a/test/widgets/app_cookies_consent_widget_test.dart b/test/widgets/app_cookies_consent_widget_test.dart new file mode 100644 index 0000000..458415b --- /dev/null +++ b/test/widgets/app_cookies_consent_widget_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/src/widgets/basic.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:zup_app/core/cache.dart'; +import 'package:zup_app/core/injections.dart'; +import 'package:zup_app/core/zup_links.dart'; +import 'package:zup_app/widgets/app_cookies_consent_widget.dart'; + +import '../golden_config.dart'; +import '../mocks.dart'; + +void main() { + late Cache cache; + late ZupLinks zupLinks; + + setUp(() { + cache = CacheMock(); + zupLinks = ZupLinksMock(); + + inject.registerFactory(() => zupLinks); + inject.registerFactory(() => cache); + }); + + tearDown(() => inject.reset()); + + Future goldenBuilder({void Function()? onAccept}) async => await goldenDeviceBuilder( + Center( + child: AppCookieConsentWidget( + onAccept: onAccept ?? () {}, + ), + ), + ); + + zGoldenTest( + "App cookies consent widget", + goldenFileName: "app_cookies_consent_widget", + (tester) async => await tester.pumpDeviceBuilder(await goldenBuilder()), + ); + + zGoldenTest( + "When clicking in the privacy policy button, it should launch the privacy policy", + (tester) async { + when(() => zupLinks.launchPrivacyPolicy()).thenAnswer((_) async {}); + + await tester.pumpDeviceBuilder(await goldenBuilder()); + + await tester.tap(find.byKey(const Key("privacy-policy-button"))); + await tester.pumpAndSettle(); + + verify(() => zupLinks.launchPrivacyPolicy()).called(1); + }, + ); + + zGoldenTest("When clicking in the understood button, it should save the cookie consent", (tester) async { + when(() => cache.saveCookiesConsentStatus(status: any(named: "status"))).thenAnswer((_) async {}); + + await tester.pumpDeviceBuilder(await goldenBuilder()); + + await tester.tap(find.byKey(const Key("accept-cookies-button"))); + await tester.pumpAndSettle(); + + verify(() => cache.saveCookiesConsentStatus(status: true)).called(1); + }); + + zGoldenTest("When clicking in the understood button, it should callback", (tester) async { + bool hasCalled = false; + + when(() => cache.saveCookiesConsentStatus(status: any(named: "status"))).thenAnswer((_) async {}); + + await tester.pumpDeviceBuilder(await goldenBuilder(onAccept: () { + hasCalled = true; + })); + + await tester.tap(find.byKey(const Key("accept-cookies-button"))); + await tester.pumpAndSettle(); + + expect(hasCalled, true); + }); +} diff --git a/test/widgets/goldens/app_cookies_consent_widget.png b/test/widgets/goldens/app_cookies_consent_widget.png new file mode 100644 index 0000000..6eb4f08 Binary files /dev/null and b/test/widgets/goldens/app_cookies_consent_widget.png differ diff --git a/web/index.html b/web/index.html index 47af8c6..f88831a 100644 --- a/web/index.html +++ b/web/index.html @@ -61,6 +61,6 @@ inject(); - +