diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 8cf961580..415cdd579 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -68,8 +68,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "278" ]; then - echo "expected 278 screenshots, got $count" >&2 + if [ "$count" != "279" ]; then + echo "expected 279 screenshots, got $count" >&2 exit 1 fi @@ -149,11 +149,11 @@ jobs: exit 1 fi - # Screenshots dir must contain all 278 PNGs assembled from Goldens. + # Screenshots dir must contain all 279 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. - # Mix of the original 01-61 range and the 62-268 batch (every Golden + # Mix of the original 01-61 range and the 62-269 batch (every Golden # baseline) so a regression in either half surfaces here. - for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default; do + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked; do code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") # 200 (auth happens to match) or 401 (auth fails but file exists) # both prove the file is on disk. 404 means it was not assembled. diff --git a/Dockerfile.handbook b/Dockerfile.handbook index fd09eaa95..a312bc852 100644 --- a/Dockerfile.handbook +++ b/Dockerfile.handbook @@ -7,7 +7,7 @@ # Build context is the repo root; only docs/handbook/, scripts/, and # test/goldens/ are copied in. # -# The 61 screenshots (`screenshots/NN-name.png`) are assembled from the +# The handbook screenshots (`screenshots/NN-name.png`) are assembled from the # visual-regression Golden baselines under `test/goldens/screens/` via # scripts/assemble-handbook-screenshots.sh — one Golden per handbook # entry, see the mapping in that script. `docs/handbook/screenshots/` is diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index d27b1caea..9b0510b53 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -372,6 +372,7 @@ "settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden", "settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsDeleteWallet": "Wallet zurücksetzen", + "settingsInsiderFeaturesUnlocked": "Insider-Funktionen freigeschaltet", "settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden", "settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsLanguages": "Sprachen", @@ -461,4 +462,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} \ No newline at end of file +} diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 913f4061a..3f26caeb1 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -372,6 +372,7 @@ "settingsCurrencyLoadFailed": "Failed to load currencies", "settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.", "settingsDeleteWallet": "Reset wallet", + "settingsInsiderFeaturesUnlocked": "Insider features unlocked", "settingsLanguageLoadFailed": "Failed to load languages", "settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.", "settingsLanguages": "Languages", @@ -461,4 +462,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} \ No newline at end of file +} diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 8eb55d72c..a5b903078 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app` / `dev-handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 279 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index b9aaa3a28..a9c2e6351 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -472,6 +472,10 @@ justify-content: center; padding: 14px; } + .test .img.img-multi { + flex-wrap: wrap; + gap: 12px; + } .test .img img { max-width: 220px; height: auto; @@ -1002,6 +1006,9 @@
  • 78Telefonnummer-Eingabe
  • +
  • + 79Insider-Freischaltung — Bezahlen & Senden +
  • WWeb · realunit.app
  • @@ -7942,6 +7949,106 @@

    78Telefonnummer-Eingabe


    +
    + +
    +
    +

    79Insider-Freischaltung — Bezahlen & Senden

    +
    test/goldens/screens/dashboard/
    +
    +
    + 2 Screens + +
    +
    +
    +

    + Die Funktionen Bezahlen (OpenCryptoPay) und Senden (REALU-Transfer + an eine andere Wallet) sind bewusst versteckt (Soft-Launch) und erscheinen erst + nach einer einmaligen Freischaltung auf dem Gerät. +

    + +
    +
    +
    + + + settings_page_default.png · dashboard_with_balance.png · dashboard_insider_unlocked.png +
    +
    +
    + Einstellungen mit Versionsnummer unten +

    Einstellungen — die Versionsnummer ganz unten ist der Tap-Auslöser.

    +
    +
    + Dashboard vor der Freischaltung — nur Kaufen und Verkaufen +

    Vorher: nur „Kaufen" und „Verkaufen".

    +
    +
    + Dashboard nach der Freischaltung — alle vier Aktionen +

    Nachher: alle vier Aktionen.

    +
    +
    +
    +

    Anleitung:

    +
      +
    1. Einstellungen öffnen (Hamburger-Icon rechts oben im Dashboard).
    2. +
    3. Ganz nach unten scrollen bis zur Versionsnummer.
    4. +
    5. Siebenmal kurz hintereinander auf die Versionsnummer tippen.
    6. +
    7. Es erscheint die Bestätigung „Insider-Funktionen freigeschaltet".
    8. +
    9. + Zurück im Dashboard stehen neben Kaufen und Verkaufen neu + Bezahlen und Senden. +
    10. +
    +

    + Die Freischaltung bleibt dauerhaft auf dem Gerät erhalten (auch nach + App-Neustart) — sie wird lokal per SharedPreferences + gespeichert. Eine Neuinstallation der App setzt sie zurück. + OpenCryptoPay-Zahlungslinks funktionieren unabhängig von der + Freischaltung — der Deeplink bleibt bewusst immer offen. Tippen auf die + Versionsnummer nach erfolgter Freischaltung hat keine Wirkung mehr. +

    +
    +
    +
    +
    + + + screens/dashboard/goldens/macos/dashboard_insider_unlocked.png +
    +
    + Dashboard mit freigeschalteten Insider-Aktionen +
    +
    + Dashboard mit freigeschalteter Insider-Ansicht: neben Kaufen und + Verkaufen erscheinen zusätzlich Bezahlen und Senden. + Golden-Quelle: dashboard_insider_unlocked.png aus + dashboard_golden_test.dart. +
    +
    +
    +
    + +
    +
    diff --git a/docs/screens.md b/docs/screens.md index 9784edae2..aceea585c 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -16,7 +16,7 @@ Column meaning: screen, or `—` if the screen has no Golden baseline. Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now - covers **all 278 Golden baselines** — every screen **plus every tested + covers **all 279 Golden baselines** — every screen **plus every tested state variant** (Default / Loading / Error / Snackbar / Dropdown / Validation / Confirm / Success / Failure …), including the areas that were previously absent: Support (email capture, tickets, chat), Settings @@ -136,7 +136,7 @@ one shared form-widget baseline (`PhoneNumberField`), which is not a screen. `243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route (`/settings/security/changePin`) via a second constructor; that reuse has no separate Golden and is not given its own row. -- **Handbook numbering.** Each of the 278 handbook slots is a Visual-Regression +- **Handbook numbering.** Each of the 279 handbook slots is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow (`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the diff --git a/lib/packages/repository/settings_repository.dart b/lib/packages/repository/settings_repository.dart index 79d513408..7031b8ada 100644 --- a/lib/packages/repository/settings_repository.dart +++ b/lib/packages/repository/settings_repository.dart @@ -47,4 +47,9 @@ class SettingsRepository { set softwareTermsAccepted(bool accepted) => _sharedPreferences.setBool('softwareTermsAccepted', accepted); + + bool get insiderFeaturesUnlocked => + _sharedPreferences.getBool('insiderFeaturesUnlocked') ?? false; + set insiderFeaturesUnlocked(bool unlocked) => + _sharedPreferences.setBool('insiderFeaturesUnlocked', unlocked); } diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart index 0c64a6a60..0e5076a06 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/action_button.dart'; @@ -10,6 +12,9 @@ class DashboardActions extends StatelessWidget { @override Widget build(BuildContext context) { + final insiderFeaturesUnlocked = + context.watch().state.insiderFeaturesUnlocked; + return Row( spacing: 10, children: [ @@ -35,28 +40,30 @@ class DashboardActions extends StatelessWidget { onPressed: () => context.pushNamed(AppRoutes.sell), ), ), - Expanded( - child: ActionButton( - icon: Icon( - Icons.qr_code_scanner_rounded, - color: RealUnitColors.basic.white, - size: 20, + if (insiderFeaturesUnlocked) + Expanded( + child: ActionButton( + icon: Icon( + Icons.qr_code_scanner_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).pay, + onPressed: () => context.pushNamed(AppRoutes.pay), ), - label: S.of(context).pay, - onPressed: () => context.pushNamed(AppRoutes.pay), ), - ), - Expanded( - child: ActionButton( - icon: Icon( - Icons.send_rounded, - color: RealUnitColors.basic.white, - size: 20, + if (insiderFeaturesUnlocked) + Expanded( + child: ActionButton( + icon: Icon( + Icons.send_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).send, + onPressed: () => context.pushNamed(AppRoutes.send), ), - label: S.of(context).send, - onPressed: () => context.pushNamed(AppRoutes.send), ), - ), ], ); } diff --git a/lib/screens/settings/bloc/settings_bloc.dart b/lib/screens/settings/bloc/settings_bloc.dart index 3c57a539c..50bc0fbdb 100644 --- a/lib/screens/settings/bloc/settings_bloc.dart +++ b/lib/screens/settings/bloc/settings_bloc.dart @@ -17,11 +17,14 @@ class SettingsBloc extends Bloc { language: Language.fromCode(_settingsRepository.language), currency: Currency.fromCode(_settingsRepository.currency), networkMode: _settingsRepository.networkMode, + insiderFeaturesUnlocked: + _settingsRepository.insiderFeaturesUnlocked, )) { on(_onSetCurrencyEvent); on(_onSetLanguageEvent); on(_onSetNetworkModeEvent); on(_onToggleHideAmountEvent); + on(_onUnlockInsiderFeaturesEvent); } final SettingsRepository _settingsRepository; @@ -53,4 +56,12 @@ class SettingsBloc extends Bloc { void _onToggleHideAmountEvent(ToggleHideAmountEvent event, Emitter emit) { emit(state.copyWith(hideAmounts: !state.hideAmounts)); } + + void _onUnlockInsiderFeaturesEvent( + UnlockInsiderFeaturesEvent event, + Emitter emit, + ) { + _settingsRepository.insiderFeaturesUnlocked = true; + emit(state.copyWith(insiderFeaturesUnlocked: true)); + } } diff --git a/lib/screens/settings/bloc/settings_event.dart b/lib/screens/settings/bloc/settings_event.dart index e65e52069..dd596c8fe 100644 --- a/lib/screens/settings/bloc/settings_event.dart +++ b/lib/screens/settings/bloc/settings_event.dart @@ -37,3 +37,7 @@ final class SetNetworkModeEvent extends SettingsEvent { final class ToggleHideAmountEvent extends SettingsEvent { const ToggleHideAmountEvent(); } + +final class UnlockInsiderFeaturesEvent extends SettingsEvent { + const UnlockInsiderFeaturesEvent(); +} diff --git a/lib/screens/settings/bloc/settings_state.dart b/lib/screens/settings/bloc/settings_state.dart index c10ac93f0..2288e2bb8 100644 --- a/lib/screens/settings/bloc/settings_state.dart +++ b/lib/screens/settings/bloc/settings_state.dart @@ -6,23 +6,27 @@ final class SettingsState { this.currency = Currency.chf, this.networkMode = NetworkMode.mainnet, this.hideAmounts = false, + this.insiderFeaturesUnlocked = false, }); final Language language; final Currency currency; final NetworkMode networkMode; final bool hideAmounts; + final bool insiderFeaturesUnlocked; SettingsState copyWith({ Language? language, Currency? currency, NetworkMode? networkMode, bool? hideAmounts, + bool? insiderFeaturesUnlocked, }) => SettingsState( language: language ?? this.language, currency: currency ?? this.currency, networkMode: networkMode ?? this.networkMode, hideAmounts: hideAmounts ?? this.hideAmounts, + insiderFeaturesUnlocked: insiderFeaturesUnlocked ?? this.insiderFeaturesUnlocked, ); } diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index 7b6d5f351..d036b7587 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_section.dart'; +import 'package:realunit_wallet/screens/settings/widgets/settings_version_unlock.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; @@ -153,16 +154,7 @@ class SettingsPage extends StatelessWidget { ), ], ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), - child: Text( - S.of(context).settingsAppVersion(releaseTag), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: RealUnitColors.neutral500, - ), - ), - ), + const SettingsVersionUnlock(releaseTag: releaseTag), ], ), ), diff --git a/lib/screens/settings/widgets/settings_version_unlock.dart b/lib/screens/settings/widgets/settings_version_unlock.dart new file mode 100644 index 000000000..d0717b6ec --- /dev/null +++ b/lib/screens/settings/widgets/settings_version_unlock.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +class SettingsVersionUnlock extends StatefulWidget { + const SettingsVersionUnlock({required this.releaseTag, super.key}); + + final String releaseTag; + + @override + State createState() => + _SettingsVersionUnlockState(); +} + +class _SettingsVersionUnlockState extends State { + int _tapCount = 0; + + void _onTap() { + final settingsBloc = getIt(); + if (settingsBloc.state.insiderFeaturesUnlocked) { + return; + } + + _tapCount++; + if (_tapCount == 7) { + settingsBloc.add(const UnlockInsiderFeaturesEvent()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).settingsInsiderFeaturesUnlocked), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + child: Text( + S.of(context).settingsAppVersion(widget.releaseTag), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart index 5cd6a9a39..a882f408f 100644 --- a/lib/widgets/action_button.dart +++ b/lib/widgets/action_button.dart @@ -31,19 +31,32 @@ class ActionButton extends StatelessWidget { ? CupertinoActivityIndicator( color: RealUnitColors.basic.white, ) - : Column( - mainAxisAlignment: .center, - children: [ - icon, - Text( - label, - textAlign: .center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: RealUnitColors.basic.white, - fontWeight: .w600, - ), + : Center( + // Expanded call-sites (dashboard_actions) squeeze this + // SizedBox's width down to ~80-84px, and large text scales + // blow past the fixed 50px height — scaleDown + a + // single-line label keeps the button overflow-free and + // fully tappable instead of wrapping/clipping (responsive + // matrix gate, issue class from PR #885). + child: FittedBox( + fit: .scaleDown, + child: Column( + mainAxisSize: .min, + children: [ + icon, + Text( + label, + maxLines: 1, + softWrap: false, + textAlign: .center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: RealUnitColors.basic.white, + fontWeight: .w600, + ), + ), + ], ), - ], + ), ), ), ), diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh index 9a55f00ea..33e716baa 100755 --- a/scripts/assemble-handbook-screenshots.sh +++ b/scripts/assemble-handbook-screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Assemble the 278 handbook screenshots from the visual-regression Golden +# Assemble the 279 handbook screenshots from the visual-regression Golden # baselines. The flat `NN-name.png` output layout matches what # docs/handbook/de/index.html links to (`` # — the relative path resolves to `docs/handbook/screenshots/NN-name.png`). @@ -316,6 +316,7 @@ MAPPING=( "266-debug-auth-page-clipboard-snackbar=screens/debug_auth/goldens/macos/debug_auth_page_clipboard_snackbar.png" "267-debug-auth-page-authenticating=screens/debug_auth/goldens/macos/debug_auth_page_authenticating.png" "268-phone-number-field-default=widgets/form/goldens/macos/phone_number_field_default.png" + "269-dashboard-insider-unlocked=screens/dashboard/goldens/macos/dashboard_insider_unlocked.png" ) missing=() diff --git a/test/goldens/screens/dashboard/dashboard_golden_test.dart b/test/goldens/screens/dashboard/dashboard_golden_test.dart index 69bb26cbe..4014ecdf5 100644 --- a/test/goldens/screens/dashboard/dashboard_golden_test.dart +++ b/test/goldens/screens/dashboard/dashboard_golden_test.dart @@ -125,5 +125,24 @@ void main() { return wrapForGolden(buildSubject()); }, ); + goldenTest( + 'insider features unlocked', + fileName: 'dashboard_insider_unlocked', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); + when(() => balanceCubit.state).thenReturn( + Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0x0', + balance: BigInt.from(5000000000000000000), + asset: realUnitAsset, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png index 6d13463d7..214c26bc5 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png new file mode 100644 index 000000000..2d41dd005 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_insider_unlocked.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png index d90b7b99b..006cbc517 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png index bbb828a45..2f81230da 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png differ diff --git a/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart b/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart index 6d22abeae..8d4475c21 100644 --- a/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart +++ b/test/goldens/screens/settings_currencies/settings_currencies_golden_test.dart @@ -26,6 +26,7 @@ void main() { when(() => settingsRepo.language).thenReturn('en'); when(() => settingsRepo.currency).thenReturn('CHF'); when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet); + when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false); when(() => fiatRepo.getAll()) .thenAnswer((_) async => const [Currency.chf, Currency.eur]); settingsBloc = SettingsBloc(settingsRepo, () async {}); diff --git a/test/goldens/screens/settings_languages/settings_languages_golden_test.dart b/test/goldens/screens/settings_languages/settings_languages_golden_test.dart index ef2658bb5..25b8dc2d2 100644 --- a/test/goldens/screens/settings_languages/settings_languages_golden_test.dart +++ b/test/goldens/screens/settings_languages/settings_languages_golden_test.dart @@ -27,6 +27,7 @@ void main() { when(() => settingsRepo.language).thenReturn('en'); when(() => settingsRepo.currency).thenReturn('CHF'); when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet); + when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false); when(() => langRepo.getEnabled()) .thenAnswer((_) async => const [Language.en, Language.de]); settingsBloc = SettingsBloc(settingsRepo, () async {}); diff --git a/test/helper/layout_assertions.dart b/test/helper/layout_assertions.dart index 4bd93030e..1e421cda8 100644 --- a/test/helper/layout_assertions.dart +++ b/test/helper/layout_assertions.dart @@ -62,7 +62,14 @@ Future expectFullyTappable( ); final box = tester.renderObject(finder); - final rect = box.localToGlobal(Offset.zero) & box.size; + // Map BOTH corners through the render transform: `localToGlobal(zero) & + // size` appends the untransformed layout size, which overstates the visual + // rect for scaled targets (e.g. inside a FittedBox) and fails containment + // on content that visibly fits. For untransformed targets both forms agree. + final rect = Rect.fromPoints( + box.localToGlobal(Offset.zero), + box.localToGlobal(box.size.bottomRight(Offset.zero)), + ); expect( rect.width, @@ -77,7 +84,10 @@ Future expectFullyTappable( expect(within, findsOneWidget, reason: 'within parent not found'); final parentBox = tester.renderObject(within); - final parentRect = parentBox.localToGlobal(Offset.zero) & parentBox.size; + final parentRect = Rect.fromPoints( + parentBox.localToGlobal(Offset.zero), + parentBox.localToGlobal(parentBox.size.bottomRight(Offset.zero)), + ); // Allow 1px float tolerance. final inflated = parentRect.inflate(1); expect( diff --git a/test/packages/repository/settings_repository_test.dart b/test/packages/repository/settings_repository_test.dart index fc5907b60..216285525 100644 --- a/test/packages/repository/settings_repository_test.dart +++ b/test/packages/repository/settings_repository_test.dart @@ -122,6 +122,32 @@ void main() { }); }); + group('insiderFeaturesUnlocked', () { + test('defaults to false when not stored', () async { + SharedPreferences.setMockInitialValues({}); + final repo = SettingsRepository(await SharedPreferences.getInstance()); + + expect(repo.insiderFeaturesUnlocked, isFalse); + }); + + test('returns the stored value when set', () async { + SharedPreferences.setMockInitialValues({'insiderFeaturesUnlocked': true}); + final repo = SettingsRepository(await SharedPreferences.getInstance()); + + expect(repo.insiderFeaturesUnlocked, isTrue); + }); + + test('setter persists', () async { + SharedPreferences.setMockInitialValues({}); + final repo = SettingsRepository(await SharedPreferences.getInstance()); + + repo.insiderFeaturesUnlocked = true; + await Future.delayed(Duration.zero); + + expect(repo.insiderFeaturesUnlocked, isTrue); + }); + }); + group('networkMode', () { test('defaults to mainnet when no value is stored', () async { SharedPreferences.setMockInitialValues({}); diff --git a/test/screens/dashboard/dashboard_responsive_matrix_test.dart b/test/screens/dashboard/dashboard_responsive_matrix_test.dart index 6fd7a62e9..f7bd104d6 100644 --- a/test/screens/dashboard/dashboard_responsive_matrix_test.dart +++ b/test/screens/dashboard/dashboard_responsive_matrix_test.dart @@ -6,6 +6,12 @@ // inside the height-bounded `Expanded > Stack` host (dashboard_page.dart) // overflowed already at default text scale with one pending transaction, // painting the CTA outside the parent's hit-testable region. +// +// A second matrix group below covers the same tappability guarantee for the +// four DashboardActions buttons (Buy/Sell/Pay/Send) in a standalone host — +// pre-existing overflow debt in the surrounding dashboard sections (e.g. +// cash_holding_box.dart) is tracked separately as issue #887 and out of +// scope for that check. import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,6 +27,7 @@ import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/dashboard_bloc.dart'; import 'package:realunit_wallet/screens/dashboard/bloc/pending_transactions_cubit.dart'; import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/currency.dart'; @@ -92,13 +99,26 @@ void main() { child: const DashboardView(), ); - // A minimal two-route stack: '/' hosts the dashboard, '/buy' is a marker - // page so a real `context.pushNamed(AppRoutes.buy)` resolves instead of - // throwing (the CTA's actual, unmocked navigation call). - GoRouter buildRouter() => GoRouter( + Widget buildActionsHost() => BlocProvider.value( + value: settingsBloc, + child: const Scaffold( + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: DashboardActions(), + ), + ), + ); + + // A minimal five-route stack: '/' hosts either the full dashboard or the + // standalone DashboardActions host (see [homeBuilder]); '/buy', '/sell', + // '/pay' and '/send' are marker pages so the four action buttons' real, + // unmocked `context.pushNamed(...)` calls resolve instead of throwing. + GoRouter buildRouter({ + Widget Function(BuildContext, GoRouterState)? homeBuilder, + }) => GoRouter( initialLocation: '/', routes: [ - GoRoute(path: '/', builder: (_, _) => buildDashboard()), + GoRoute(path: '/', builder: homeBuilder ?? (_, _) => buildDashboard()), GoRoute( name: AppRoutes.buy, path: '/buy', @@ -106,6 +126,27 @@ void main() { body: Center(child: Text('buy-page-marker')), ), ), + GoRoute( + name: AppRoutes.sell, + path: '/sell', + builder: (_, _) => const Scaffold( + body: Center(child: Text('sell-page-marker')), + ), + ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, _) => const Scaffold( + body: Center(child: Text('pay-page-marker')), + ), + ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const Scaffold( + body: Center(child: Text('send-page-marker')), + ), + ), ], ); @@ -141,6 +182,34 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } + Future pumpActions(WidgetTester tester, MatrixCell cell) async { + final router = buildRouter(homeBuilder: (_, _) => buildActionsHost()); + addTearDown(router.dispose); + + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + routerConfig: router, + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + group('DashboardView responsive matrix - empty balance, buy CTA reachable ' '(full device x textScale)', () { for (final cell in kFullResponsiveMatrix) { @@ -172,6 +241,60 @@ void main() { } }); + group( + 'DashboardActions responsive matrix - insider unlocked, all four actions ' + 'tappable (full device x textScale)', + () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); + + await expectNoLayoutOverflow( + tester, + () async { + await pumpActions(tester, cell); + }, + reason: 'overflow on insider unlocked / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.text(S.current.buy), + within: find.byType(DashboardActions), + reason: '${cell.label}: buy button not tappable', + ); + + await pumpActions(tester, cell); + await expectFullyTappable( + tester, + find.text(S.current.sell), + within: find.byType(DashboardActions), + reason: '${cell.label}: sell button not tappable', + ); + + await pumpActions(tester, cell); + await expectFullyTappable( + tester, + find.text(S.current.pay), + within: find.byType(DashboardActions), + reason: '${cell.label}: pay button not tappable', + ); + + await pumpActions(tester, cell); + await expectFullyTappable( + tester, + find.text(S.current.send), + within: find.byType(DashboardActions), + reason: '${cell.label}: send button not tappable', + ); + }); + }); + } + }, + ); + // Focused regression: the exact reported failure mode (empty balance, one // waitingForPayment pending tx, default text scale) must invoke the real // navigation via a tap - not just have a non-null onPressed. The old code diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart index 0192a2e36..2c0aa6f86 100644 --- a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -1,22 +1,29 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/widgets/action_button.dart'; +import '../../../../helper/helper.dart'; + void main() { late List pushedRoutes; + late MockSettingsBloc settingsBloc; setUp(() { pushedRoutes = []; + settingsBloc = MockSettingsBloc(); }); - // Routes the four action buttons can push. Each target records the pushed - // route name so the `onPressed` closures are both executed and asserted, - // instead of only painted. GoRouter buildRouter() { GoRoute target(String name, String path) => GoRoute( name: name, @@ -32,7 +39,12 @@ void main() { routes: [ GoRoute( path: '/', - builder: (_, _) => const Scaffold(body: DashboardActions()), + builder: (_, _) => Scaffold( + body: BlocProvider.value( + value: settingsBloc, + child: const DashboardActions(), + ), + ), ), target(AppRoutes.buy, '/buy'), target(AppRoutes.sell, '/sell'), @@ -48,76 +60,130 @@ void main() { await tester.pumpWidget( MaterialApp.router( routerConfig: router, - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - ], + localizationsDelegates: const [S.delegate, GlobalMaterialLocalizations.delegate], supportedLocales: S.delegate.supportedLocales, ), ); await tester.pumpAndSettle(); } - Finder actionButtonByLabel(String label) => find.byWidgetPredicate( - (w) => w is ActionButton && w.label == label, - ); + Finder actionButtonByLabel(String label) => + find.byWidgetPredicate((w) => w is ActionButton && w.label == label); group('$DashboardActions', () { - testWidgets('renders the buy, sell, pay and send action buttons', (tester) async { - await pumpActions(tester); - - expect(actionButtonByLabel(S.current.buy), findsOneWidget); - expect(actionButtonByLabel(S.current.sell), findsOneWidget); - expect(actionButtonByLabel(S.current.pay), findsOneWidget); - expect(actionButtonByLabel(S.current.send), findsOneWidget); - // Each button is laid out inside an Expanded so the row divides the - // available width into four equal slots. - expect(find.byType(Expanded), findsNWidgets(4)); + group('locked (default)', () { + setUp(() { + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + testWidgets('renders only the buy and sell action buttons', (tester) async { + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.buy), findsOneWidget); + expect(actionButtonByLabel(S.current.sell), findsOneWidget); + expect(actionButtonByLabel(S.current.pay), findsNothing); + expect(actionButtonByLabel(S.current.send), findsNothing); + expect(find.byType(Expanded), findsNWidgets(2)); + }); + + testWidgets('buy button pushes the buy route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.buy)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.buy]); + }); + + testWidgets('sell button pushes the sell route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.sell)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.sell]); + }); }); - testWidgets('renders the expected icons for each action', (tester) async { - await pumpActions(tester); - - expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); - expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); - expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); - expect(find.byIcon(Icons.send_rounded), findsOneWidget); + group('unlocked', () { + setUp(() { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); + }); + + testWidgets('renders the buy, sell, pay and send action buttons', (tester) async { + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.buy), findsOneWidget); + expect(actionButtonByLabel(S.current.sell), findsOneWidget); + expect(actionButtonByLabel(S.current.pay), findsOneWidget); + expect(actionButtonByLabel(S.current.send), findsOneWidget); + expect(find.byType(Expanded), findsNWidgets(4)); + }); + + testWidgets('renders the expected icons for each action', (tester) async { + await pumpActions(tester); + + expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); + expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); + expect(find.byIcon(Icons.send_rounded), findsOneWidget); + }); + + testWidgets('buy button pushes the buy route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.buy)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.buy]); + }); + + testWidgets('sell button pushes the sell route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.sell)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.sell]); + }); + + testWidgets('pay button pushes the pay route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.pay)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.pay]); + }); + + testWidgets('send button pushes the send route', (tester) async { + await pumpActions(tester); + await tester.tap(actionButtonByLabel(S.current.send)); + await tester.pumpAndSettle(); + expect(pushedRoutes, [AppRoutes.send]); + }); }); - testWidgets('buy button pushes the buy route', (tester) async { - await pumpActions(tester); - - await tester.tap(actionButtonByLabel(S.current.buy)); - await tester.pumpAndSettle(); - - expect(pushedRoutes, [AppRoutes.buy]); - }); - - testWidgets('sell button pushes the sell route', (tester) async { - await pumpActions(tester); - - await tester.tap(actionButtonByLabel(S.current.sell)); - await tester.pumpAndSettle(); - - expect(pushedRoutes, [AppRoutes.sell]); - }); - - testWidgets('pay button pushes the pay route', (tester) async { - await pumpActions(tester); - - await tester.tap(actionButtonByLabel(S.current.pay)); - await tester.pumpAndSettle(); - - expect(pushedRoutes, [AppRoutes.pay]); - }); - - testWidgets('send button pushes the send route', (tester) async { - await pumpActions(tester); - - await tester.tap(actionButtonByLabel(S.current.send)); - await tester.pumpAndSettle(); - - expect(pushedRoutes, [AppRoutes.send]); + group('transitions', () { + testWidgets( + 'rebuilds from locked to unlocked when the bloc emits without a remount ' + '(pins context.watch, a regression to context.read would not react)', + (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + whenListen( + settingsBloc, + controller.stream, + initialState: const SettingsState(), + ); + + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.pay), findsNothing); + expect(actionButtonByLabel(S.current.send), findsNothing); + + controller.add(const SettingsState(insiderFeaturesUnlocked: true)); + // Two pumps: the first delivers the stream event (async broadcast + // delivery updates the mock's state and marks the element dirty), + // the second builds the frame that shows the unlocked buttons. + await tester.pump(); + await tester.pump(); + + expect(actionButtonByLabel(S.current.pay), findsOneWidget); + expect(actionButtonByLabel(S.current.send), findsOneWidget); + }, + ); }); }); } diff --git a/test/screens/settings/settings_bloc_test.dart b/test/screens/settings/settings_bloc_test.dart index c84bd2859..525cff27e 100644 --- a/test/screens/settings/settings_bloc_test.dart +++ b/test/screens/settings/settings_bloc_test.dart @@ -20,6 +20,7 @@ void main() { when(() => repo.language).thenReturn('en'); when(() => repo.currency).thenReturn('CHF'); when(() => repo.networkMode).thenReturn(NetworkMode.mainnet); + when(() => repo.insiderFeaturesUnlocked).thenReturn(false); }); SettingsBloc build() => SettingsBloc( @@ -34,6 +35,7 @@ void main() { when(() => repo.language).thenReturn('de'); when(() => repo.currency).thenReturn('EUR'); when(() => repo.networkMode).thenReturn(NetworkMode.testnet); + when(() => repo.insiderFeaturesUnlocked).thenReturn(true); final bloc = build(); @@ -41,6 +43,7 @@ void main() { expect(bloc.state.currency, Currency.eur); expect(bloc.state.networkMode, NetworkMode.testnet); expect(bloc.state.hideAmounts, isFalse); + expect(bloc.state.insiderFeaturesUnlocked, isTrue); }); blocTest( @@ -123,5 +126,15 @@ void main() { expect(bloc.state.hideAmounts, isTrue); verifyNever(() => repo.language = any()); // proxy: no repo call at all }); + + blocTest( + 'UnlockInsiderFeaturesEvent persists to the repo and emits insiderFeaturesUnlocked=true', + build: build, + act: (bloc) => bloc.add(const UnlockInsiderFeaturesEvent()), + verify: (bloc) { + expect(bloc.state.insiderFeaturesUnlocked, isTrue); + verify(() => repo.insiderFeaturesUnlocked = true).called(1); + }, + ); }); } diff --git a/test/screens/settings/settings_event_test.dart b/test/screens/settings/settings_event_test.dart index def20d394..72015be82 100644 --- a/test/screens/settings/settings_event_test.dart +++ b/test/screens/settings/settings_event_test.dart @@ -82,6 +82,18 @@ void main() { }); }); + group('UnlockInsiderFeaturesEvent', () { + test('all instances are equal (singleton-style event, no payload)', () { + const a = UnlockInsiderFeaturesEvent(); + const b = UnlockInsiderFeaturesEvent(); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + // Inherits the empty `props` list from the sealed base class. + expect(a.props, isEmpty); + }); + }); + group('SettingsEvent (cross-subclass identity)', () { test('different subclasses are not equal even when props happen to match', () { // Two payload-less events from different subclasses must still compare @@ -105,16 +117,19 @@ void main() { final cur = SetCurrencyEvent(Currency.eur); final net = SetNetworkModeEvent(NetworkMode.mainnet); final toggle = ToggleHideAmountEvent(); + final unlock = UnlockInsiderFeaturesEvent(); expect(lang, equals(const SetLanguageEvent(Language.de))); expect(cur, equals(const SetCurrencyEvent(Currency.eur))); expect(net, equals(const SetNetworkModeEvent(NetworkMode.mainnet))); expect(toggle, equals(const ToggleHideAmountEvent())); + expect(unlock, equals(const UnlockInsiderFeaturesEvent())); expect(lang.props, [Language.de]); expect(cur.props, [Currency.eur]); expect(net.props, [NetworkMode.mainnet]); expect(toggle.props, isEmpty); + expect(unlock.props, isEmpty); }); }); } diff --git a/test/screens/settings/widgets/settings_version_unlock_test.dart b/test/screens/settings/widgets/settings_version_unlock_test.dart new file mode 100644 index 000000000..a29d4a3b5 --- /dev/null +++ b/test/screens/settings/widgets/settings_version_unlock_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/screens/settings/widgets/settings_version_unlock.dart'; + +import '../../../helper/helper.dart'; + +void main() { + late MockSettingsBloc settingsBloc; + + setUpAll(() { + GetIt.instance.registerSingleton(MockSettingsBloc()); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + setUp(() { + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + }); + + Widget host() { + // Re-register the per-test mock into GetIt so getIt() inside the + // widget picks up the state defined in setUp(). setUpAll() only registers once. + if (GetIt.instance.isRegistered()) { + GetIt.instance.unregister(); + } + GetIt.instance.registerSingleton(settingsBloc); + + return const Scaffold( + body: SettingsVersionUnlock(releaseTag: '1.2.3'), + ); + } + + group('$SettingsVersionUnlock', () { + testWidgets('displays the version text', (tester) async { + await tester.pumpApp(host()); + + expect(find.textContaining('1.2.3'), findsOneWidget); + }); + + testWidgets('6 taps: no event dispatched, no SnackBar shown', (tester) async { + await tester.pumpApp(host()); + + for (var i = 0; i < 6; i++) { + await tester.tap(find.byType(SettingsVersionUnlock)); + await tester.pump(); + } + + verifyNever(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets( + '7th tap dispatches UnlockInsiderFeaturesEvent exactly once and shows the SnackBar', + (tester) async { + await tester.pumpApp(host()); + + for (var i = 0; i < 7; i++) { + await tester.tap(find.byType(SettingsVersionUnlock)); + await tester.pump(); + } + + verify(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())).called(1); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text(S.current.settingsInsiderFeaturesUnlocked), findsOneWidget); + }, + ); + + testWidgets('already unlocked: taps trigger neither event nor SnackBar', (tester) async { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); + + await tester.pumpApp(host()); + + for (var i = 0; i < 10; i++) { + await tester.tap(find.byType(SettingsVersionUnlock)); + await tester.pump(); + } + + verifyNever(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('already unlocked: version text is still displayed', (tester) async { + when(() => settingsBloc.state) + .thenReturn(const SettingsState(insiderFeaturesUnlocked: true)); + + await tester.pumpApp(host()); + + expect(find.textContaining('1.2.3'), findsOneWidget); + }); + + testWidgets( + 'nine taps still dispatch UnlockInsiderFeaturesEvent exactly once ' + '(pins the == 7 comparison, a regression to >= 7 would fire on every tap after)', + (tester) async { + await tester.pumpApp(host()); + + for (var i = 0; i < 9; i++) { + await tester.tap(find.byType(SettingsVersionUnlock)); + await tester.pump(); + } + + verify(() => settingsBloc.add(const UnlockInsiderFeaturesEvent())).called(1); + expect(find.byType(SnackBar), findsOneWidget); + }, + ); + }); +} diff --git a/test/screens/settings_currencies/settings_currencies_page_test.dart b/test/screens/settings_currencies/settings_currencies_page_test.dart index c46d2b01c..e7537f0ae 100644 --- a/test/screens/settings_currencies/settings_currencies_page_test.dart +++ b/test/screens/settings_currencies/settings_currencies_page_test.dart @@ -26,6 +26,7 @@ void main() { when(() => settingsRepo.language).thenReturn('en'); when(() => settingsRepo.currency).thenReturn('CHF'); when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet); + when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false); settingsBloc = SettingsBloc(settingsRepo, () async {}); final getIt = GetIt.instance; diff --git a/test/screens/settings_languages/settings_languages_page_test.dart b/test/screens/settings_languages/settings_languages_page_test.dart index 9a4e6e15e..3d3b58f58 100644 --- a/test/screens/settings_languages/settings_languages_page_test.dart +++ b/test/screens/settings_languages/settings_languages_page_test.dart @@ -27,6 +27,7 @@ void main() { when(() => settingsRepo.language).thenReturn('en'); when(() => settingsRepo.currency).thenReturn('CHF'); when(() => settingsRepo.networkMode).thenReturn(NetworkMode.mainnet); + when(() => settingsRepo.insiderFeaturesUnlocked).thenReturn(false); settingsBloc = SettingsBloc(settingsRepo, () async {}); final getIt = GetIt.instance;