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 @@
+ 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.
+
Einstellungen — die Versionsnummer ganz unten ist der Tap-Auslöser.
+
+
+
+
Vorher: nur „Kaufen" und „Verkaufen".
+
+
+
+
Nachher: alle vier Aktionen.
+
+
+
+
Anleitung:
+
+
Einstellungen öffnen (Hamburger-Icon rechts oben im Dashboard).
+
Ganz nach unten scrollen bis zur Versionsnummer.
+
Siebenmal kurz hintereinander auf die Versionsnummer tippen.
+
Es erscheint die Bestätigung „Insider-Funktionen freigeschaltet".
+
+ Zurück im Dashboard stehen neben Kaufen und Verkaufen neu
+ Bezahlen und Senden.
+
+
+
+ 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.
+
+ 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;