From e3a7eab04794314a9e6636d052f6c7f1824fa4be Mon Sep 17 00:00:00 2001 From: Dhruvan Bhalara <53393418+dhruvanbhalara@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:04:00 +0530 Subject: [PATCH 1/4] refactor(example): map UI logic dynamically and fix slider boundary crash - Decouple state logic from direct strategy instances - Prevent re-entrant state updates in the main layout - Isolate strategy specific metadata to ensure slider bounds respect physical limits - De-duplicate and string buffer loop allocations --- example/lib/main.dart | 12 +- example/lib/state/generator_state.dart | 127 +++++++++++------- .../lib/strategies/app_strategy_config.dart | 75 +++++++++++ .../lib/strategies/custom_pin_strategy.dart | 4 +- .../memorable_password_strategy.dart | 3 +- .../pronounceable_password_strategy.dart | 8 +- .../customize_character_sets_dialog.dart | 13 -- example/lib/widgets/password_options.dart | 38 ++---- .../custom_pin_strategy_controls.dart | 10 +- .../memorable_strategy_controls.dart | 10 +- .../pronounceable_strategy_controls.dart | 10 +- .../strategies/random_strategy_controls.dart | 10 +- .../lib/widgets/strategy_controls_panel.dart | 32 +---- example/pubspec.lock | 8 +- .../customize_character_sets_dialog_test.dart | 5 - example/test/password_options_test.dart | 3 +- 16 files changed, 222 insertions(+), 146 deletions(-) create mode 100644 example/lib/strategies/app_strategy_config.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index a0f3d60..1296310 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -65,10 +65,14 @@ class _PasswordExampleState extends State void _onStateChanged() { if (_state.errorMessage != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_state.errorMessage!)), - ); - _state.clearError(); + final errorMessage = _state.errorMessage!; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(errorMessage)), + ); + _state.clearError(); + }); } if (_state.password.value != _lastPassword) { diff --git a/example/lib/state/generator_state.dart b/example/lib/state/generator_state.dart index f42c245..8deff5c 100644 --- a/example/lib/state/generator_state.dart +++ b/example/lib/state/generator_state.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import 'package:password_engine/password_engine.dart'; -import '../constants/words.dart'; import '../policies/custom_corporate_policy.dart'; -import '../strategies/custom_pin_strategy.dart'; -import '../strategies/memorable_password_strategy.dart'; -import '../strategies/pronounceable_password_strategy.dart'; +import '../strategies/app_strategy_config.dart'; import '../strength_estimators/zxcvbn_strength_estimator.dart'; enum CharacterType { upper, lower, numbers, special, spaces } @@ -34,6 +31,34 @@ class GeneratorState extends ChangeNotifier { double _length = 16; double get length => _length; + double get sliderMinLength { + final baseMin = _selectedStrategyConfig.minLength; + if (_selectedStrategyConfig.name != 'Random' && + _selectedStrategyConfig.name != 'Pronounceable') { + return baseMin; // Word/PIN formats ignore strict char count policies visually. + } + if (_useCorporatePolicy){ + return CustomCorporatePolicy.strictPolicy.minLength.toDouble();} + if (_usePolicy && _policyMinLength > baseMin) return _policyMinLength; + return baseMin; + } + + double get sliderMaxLength { + final baseMax = _selectedStrategyConfig.maxLength; + if (_selectedStrategyConfig.name != 'Random' && + _selectedStrategyConfig.name != 'Pronounceable') { + return baseMax; + } + if (_useCorporatePolicy) return baseMax; + if (_usePolicy && _usePolicyMaxLength && _policyMaxLength < baseMax) { + // Ensure max isn't physically lower than the current min bounds + return _policyMaxLength < sliderMinLength + ? sliderMinLength + : _policyMaxLength; + } + return baseMax; + } + bool _useUpperCase = true; bool get useUpperCase => _useUpperCase; @@ -103,22 +128,16 @@ class GeneratorState extends ChangeNotifier { static const _LowercasePasswordNormalizer _blocklistNormalizer = _LowercasePasswordNormalizer(); - late final List strategies; - late IPasswordGenerationStrategy _selectedStrategy; - IPasswordGenerationStrategy get selectedStrategy => _selectedStrategy; + late final List strategies; + late AppStrategyConfig _selectedStrategyConfig; + AppStrategyConfig get selectedStrategyConfig => _selectedStrategyConfig; String _prefix = 'USER'; String get prefix => _prefix; GeneratorState() { - strategies = [ - RandomPasswordStrategy(), - PassphrasePasswordStrategy(wordlist: words, separator: ' '), - MemorablePasswordStrategy(), - PronounceablePasswordStrategy(), - CustomPinStrategy(), - ]; - _selectedStrategy = strategies[0]; + strategies = availableAppStrategies; + _selectedStrategyConfig = strategies[0]; generatePassword(); } @@ -192,7 +211,7 @@ class GeneratorState extends ChangeNotifier { _generator = PasswordGenerator( validator: baseValidator, - generationStrategy: _selectedStrategy, + generationStrategy: _selectedStrategyConfig.strategy, strengthEstimator: _useZxcvbn ? ExampleZxcvbnStrengthEstimator(userInputs: _collectUserInputs()) : null, @@ -212,20 +231,26 @@ class GeneratorState extends ChangeNotifier { ? _withSpaces(_characterSetProfile) : _characterSetProfile; - _generator.updateConfig( - PasswordGeneratorConfigBuilder() - .length(_length.round()) - .useUpperCase(_useUpperCase) - .useLowerCase(_useLowerCase) - .useNumbers(_useNumbers) - .useSpecialChars(_useSpecialChars) - .excludeAmbiguousChars(_excludeAmbiguousChars) - .characterSetProfile(profile) - .maxGenerationAttempts(_maxGenerationAttempts) - .policy(policy) - .extra('prefix', _prefix) - .build(), - ); + final configBuilder = PasswordGeneratorConfigBuilder() + .length(_length.round()) + .useUpperCase(_useUpperCase) + .useLowerCase(_useLowerCase) + .useNumbers(_useNumbers) + .useSpecialChars(_useSpecialChars) + .excludeAmbiguousChars(_excludeAmbiguousChars) + .characterSetProfile(profile) + .maxGenerationAttempts(_maxGenerationAttempts) + .policy(policy) + .extra('prefix', _prefix); + + if (_selectedStrategyConfig.name == 'Passphrase' || + _selectedStrategyConfig.name == 'Memorable') { + configBuilder.extra('wordCount', _length.round()); + } else if (_selectedStrategyConfig.name == 'Custom PIN') { + configBuilder.extra('numericLength', _length.round()); + } + + _generator.updateConfig(configBuilder.build()); } PasswordPolicy? _buildPolicy() { @@ -276,29 +301,30 @@ class GeneratorState extends ChangeNotifier { generatePassword(); } - void setStrategy(IPasswordGenerationStrategy strategy) { - _selectedStrategy = strategy; - if (_selectedStrategy is RandomPasswordStrategy) { - if (_length < 16) _length = 16; - if (_length > 128) _length = 128; // Ensure it respects typical bounds - } else if (_selectedStrategy is PassphrasePasswordStrategy) { - if (_length < 4) _length = 4; - if (_length > 8) _length = 8; - } else if (_selectedStrategy is MemorablePasswordStrategy) { - if (_length < 4) _length = 4; - if (_length > 8) _length = 8; - } else if (_selectedStrategy is PronounceablePasswordStrategy) { - if (_length < 8) _length = 8; - if (_length > 20) _length = 20; - } else if (_selectedStrategy is CustomPinStrategy) { - if (_length < 4) _length = 4; - if (_length > 12) _length = 12; - } + void _clampLengthToPolicy() { + double tempLength = _length; + + // Always fall within the newly bounded UI layout constraints instead + final double safeMin = sliderMinLength; + final double safeMax = sliderMaxLength; + + if (tempLength < safeMin) tempLength = safeMin; + if (tempLength > safeMax) tempLength = safeMax; + + _length = tempLength; + } + + void setStrategyConfig(AppStrategyConfig config) { + _selectedStrategyConfig = config; + if (_length < config.minLength) _length = config.minLength; + if (_length > config.maxLength) _length = config.maxLength; + _clampLengthToPolicy(); generatePassword(); } void setLength(double value) { _length = value; + _clampLengthToPolicy(); generatePassword(); } @@ -374,11 +400,13 @@ class GeneratorState extends ChangeNotifier { void togglePolicyEnabled(bool value) { _usePolicy = value; + _clampLengthToPolicy(); generatePassword(); } void toggleCorporatePolicy(bool value) { _useCorporatePolicy = value; + _clampLengthToPolicy(); generatePassword(); } @@ -387,6 +415,7 @@ class GeneratorState extends ChangeNotifier { if (_usePolicyMaxLength && _policyMaxLength < _policyMinLength) { _policyMaxLength = _policyMinLength; } + _clampLengthToPolicy(); generatePassword(); } @@ -395,11 +424,13 @@ class GeneratorState extends ChangeNotifier { if (value && _policyMaxLength < _policyMinLength) { _policyMaxLength = _policyMinLength; } + _clampLengthToPolicy(); generatePassword(); } void setPolicyMaxLength(double value) { _policyMaxLength = value; + _clampLengthToPolicy(); generatePassword(); } diff --git a/example/lib/strategies/app_strategy_config.dart b/example/lib/strategies/app_strategy_config.dart new file mode 100644 index 0000000..253b2ce --- /dev/null +++ b/example/lib/strategies/app_strategy_config.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:password_engine/password_engine.dart'; + +import '../constants/words.dart'; +import '../state/generator_state.dart'; +import '../widgets/strategies/custom_pin_strategy_controls.dart'; +import '../widgets/strategies/memorable_strategy_controls.dart'; +import '../widgets/strategies/pronounceable_strategy_controls.dart'; +import '../widgets/strategies/random_strategy_controls.dart'; +import 'custom_pin_strategy.dart'; +import 'memorable_password_strategy.dart'; +import 'pronounceable_password_strategy.dart'; + +/// Configuration wrapper for password generation strategies inside the app. +/// This respects the Open/Closed principle by mapping configuration and metadata +/// outside of the actual state variables, avoiding explicit type checks (`is`). +class AppStrategyConfig { + final String name; + final IPasswordGenerationStrategy strategy; + final double minLength; + final double maxLength; + final Widget Function( + GeneratorState state, TextEditingController prefixController)? + controlBuilder; + + const AppStrategyConfig({ + required this.name, + required this.strategy, + required this.minLength, + required this.maxLength, + this.controlBuilder, + }); +} + +List get availableAppStrategies => [ + AppStrategyConfig( + name: 'Random', + strategy: RandomPasswordStrategy(), + minLength: 8, + maxLength: 128, + controlBuilder: (state, _) => RandomStrategyControls(state: state), + ), + AppStrategyConfig( + name: 'Passphrase', + strategy: PassphrasePasswordStrategy(wordlist: words, separator: ' '), + minLength: 4, + maxLength: 8, + controlBuilder: (state, _) => MemorableStrategyControls(state: state), + ), + AppStrategyConfig( + name: 'Memorable', + strategy: MemorablePasswordStrategy(), + minLength: 4, + maxLength: 8, + controlBuilder: (state, _) => MemorableStrategyControls(state: state), + ), + AppStrategyConfig( + name: 'Pronounceable', + strategy: PronounceablePasswordStrategy(), + minLength: 8, + maxLength: 20, + controlBuilder: (state, _) => + PronounceableStrategyControls(state: state), + ), + AppStrategyConfig( + name: 'Custom PIN', + strategy: CustomPinStrategy(), + minLength: 4, + maxLength: 12, + controlBuilder: (state, prefixController) => CustomPinStrategyControls( + state: state, + prefixController: prefixController, + ), + ), + ]; diff --git a/example/lib/strategies/custom_pin_strategy.dart b/example/lib/strategies/custom_pin_strategy.dart index ad7ec9f..628613c 100644 --- a/example/lib/strategies/custom_pin_strategy.dart +++ b/example/lib/strategies/custom_pin_strategy.dart @@ -12,6 +12,8 @@ class CustomPinStrategy implements IPasswordGenerationStrategy { validate(config); final prefix = config.extra['prefix'] as String? ?? 'USER'; + final numericLength = + config.extra['numericLength'] as int? ?? config.length; final random = Random.secure(); final buffer = StringBuffer(); @@ -19,7 +21,7 @@ class CustomPinStrategy implements IPasswordGenerationStrategy { buffer.write('$prefix-'); // Generate numeric part - for (var i = 0; i < config.length; i++) { + for (var i = 0; i < numericLength; i++) { buffer.write(random.nextInt(10)); } diff --git a/example/lib/strategies/memorable_password_strategy.dart b/example/lib/strategies/memorable_password_strategy.dart index 87ac345..1fc771b 100644 --- a/example/lib/strategies/memorable_password_strategy.dart +++ b/example/lib/strategies/memorable_password_strategy.dart @@ -23,8 +23,9 @@ class MemorablePasswordStrategy implements IPasswordGenerationStrategy { String generate(PasswordGeneratorConfig config) { validate(config); + final wordCount = config.extra['wordCount'] as int? ?? config.length; List passwordWords = []; - for (int i = 0; i < config.length; i++) { + for (int i = 0; i < wordCount; i++) { String word = words[_random.nextInt(words.length)]; if (capitalize) { word = word[0].toUpperCase() + word.substring(1); diff --git a/example/lib/strategies/pronounceable_password_strategy.dart b/example/lib/strategies/pronounceable_password_strategy.dart index add1348..980d211 100644 --- a/example/lib/strategies/pronounceable_password_strategy.dart +++ b/example/lib/strategies/pronounceable_password_strategy.dart @@ -19,15 +19,15 @@ class PronounceablePasswordStrategy implements IPasswordGenerationStrategy { String generate(PasswordGeneratorConfig config) { validate(config); - String password = ''; + final buffer = StringBuffer(); for (int i = 0; i < config.length; i++) { if (i % 2 == 0) { - password += _consonants[_random.nextInt(_consonants.length)]; + buffer.write(_consonants[_random.nextInt(_consonants.length)]); } else { - password += _vowels[_random.nextInt(_vowels.length)]; + buffer.write(_vowels[_random.nextInt(_vowels.length)]); } } - return password; + return buffer.toString(); } @override diff --git a/example/lib/widgets/customize_character_sets_dialog.dart b/example/lib/widgets/customize_character_sets_dialog.dart index 1f1e503..13e53f3 100644 --- a/example/lib/widgets/customize_character_sets_dialog.dart +++ b/example/lib/widgets/customize_character_sets_dialog.dart @@ -152,19 +152,6 @@ class _CustomizeCharacterSetsDialogState TextButton( onPressed: () { const profile = CharacterSetProfile.defaultProfile; - setState(() { - _upperCaseController.text = profile.upperCaseLetters; - _lowerCaseController.text = profile.lowerCaseLetters; - _numbersController.text = profile.numbers; - _specialCharsController.text = profile.specialCharacters; - _upperCaseNonAmbiguousController.text = - profile.upperCaseLettersNonAmbiguous; - _lowerCaseNonAmbiguousController.text = - profile.lowerCaseLettersNonAmbiguous; - _numbersNonAmbiguousController.text = profile.numbersNonAmbiguous; - _specialCharsNonAmbiguousController.text = - profile.specialCharactersNonAmbiguous; - }); widget.onSave(profile); Navigator.of(context).pop(); }, diff --git a/example/lib/widgets/password_options.dart b/example/lib/widgets/password_options.dart index cb5329c..a4e138b 100644 --- a/example/lib/widgets/password_options.dart +++ b/example/lib/widgets/password_options.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:password_engine/password_engine.dart'; import '../state/generator_state.dart'; -import '../strategies/custom_pin_strategy.dart'; -import '../strategies/memorable_password_strategy.dart'; -import '../strategies/pronounceable_password_strategy.dart'; +import '../strategies/app_strategy_config.dart'; class PasswordOptions extends StatelessWidget { final GeneratorState state; @@ -14,21 +11,6 @@ class PasswordOptions extends StatelessWidget { required this.state, }); - String _getStrategyName(IPasswordGenerationStrategy strategy) { - if (strategy is RandomPasswordStrategy) { - return 'Random'; - } else if (strategy is PassphrasePasswordStrategy) { - return 'Passphrase'; - } else if (strategy is MemorablePasswordStrategy) { - return 'Memorable'; - } else if (strategy is PronounceablePasswordStrategy) { - return 'Pronounceable'; - } else if (strategy is CustomPinStrategy) { - return 'Custom PIN'; - } - return 'Unknown'; - } - @override Widget build(BuildContext context) { return Container( @@ -59,19 +41,19 @@ class PasswordOptions extends StatelessWidget { border: OutlineInputBorder(), ), child: DropdownButtonHideUnderline( - child: DropdownButton( + child: DropdownButton( key: const Key('strategy_dropdown'), - value: state.selectedStrategy, + value: state.selectedStrategyConfig, isDense: true, - items: state.strategies.map((strategy) { - return DropdownMenuItem( - value: strategy, - child: Text(_getStrategyName(strategy)), + items: state.strategies.map((config) { + return DropdownMenuItem( + value: config, + child: Text(config.name), ); }).toList(), - onChanged: (strategy) { - if (strategy != null) { - state.setStrategy(strategy); + onChanged: (config) { + if (config != null) { + state.setStrategyConfig(config); } }, ), diff --git a/example/lib/widgets/strategies/custom_pin_strategy_controls.dart b/example/lib/widgets/strategies/custom_pin_strategy_controls.dart index 8857537..ca86948 100644 --- a/example/lib/widgets/strategies/custom_pin_strategy_controls.dart +++ b/example/lib/widgets/strategies/custom_pin_strategy_controls.dart @@ -37,9 +37,13 @@ class CustomPinStrategyControls extends StatelessWidget { flex: 2, child: Slider( value: state.length, - min: 4, - max: 12, - divisions: 8, + min: state.sliderMinLength, + max: state.sliderMaxLength, + divisions: + (state.sliderMaxLength - state.sliderMinLength).round() > 0 + ? (state.sliderMaxLength - state.sliderMinLength) + .round() + : 1, label: state.length.round().toString(), onChanged: state.setLength, ), diff --git a/example/lib/widgets/strategies/memorable_strategy_controls.dart b/example/lib/widgets/strategies/memorable_strategy_controls.dart index d453114..b028c84 100644 --- a/example/lib/widgets/strategies/memorable_strategy_controls.dart +++ b/example/lib/widgets/strategies/memorable_strategy_controls.dart @@ -27,9 +27,13 @@ class MemorableStrategyControls extends StatelessWidget { child: Slider( key: const Key('memorable_length_slider'), value: state.length, - min: 4, - max: 8, - divisions: 4, + min: state.sliderMinLength, + max: state.sliderMaxLength, + divisions: + (state.sliderMaxLength - state.sliderMinLength).round() > 0 + ? (state.sliderMaxLength - state.sliderMinLength) + .round() + : 1, label: state.length.round().toString(), onChanged: state.setLength, ), diff --git a/example/lib/widgets/strategies/pronounceable_strategy_controls.dart b/example/lib/widgets/strategies/pronounceable_strategy_controls.dart index 2944f5c..de3b3a8 100644 --- a/example/lib/widgets/strategies/pronounceable_strategy_controls.dart +++ b/example/lib/widgets/strategies/pronounceable_strategy_controls.dart @@ -26,9 +26,13 @@ class PronounceableStrategyControls extends StatelessWidget { flex: 2, child: Slider( value: state.length, - min: 8, - max: 20, - divisions: 12, + min: state.sliderMinLength, + max: state.sliderMaxLength, + divisions: + (state.sliderMaxLength - state.sliderMinLength).round() > 0 + ? (state.sliderMaxLength - state.sliderMinLength) + .round() + : 1, label: state.length.round().toString(), onChanged: state.setLength, ), diff --git a/example/lib/widgets/strategies/random_strategy_controls.dart b/example/lib/widgets/strategies/random_strategy_controls.dart index 05f2f43..646a828 100644 --- a/example/lib/widgets/strategies/random_strategy_controls.dart +++ b/example/lib/widgets/strategies/random_strategy_controls.dart @@ -27,9 +27,13 @@ class RandomStrategyControls extends StatelessWidget { child: Slider( key: const Key('random_length_slider'), value: state.length, - min: 16, - max: 128, - divisions: 112, + min: state.sliderMinLength, + max: state.sliderMaxLength, + divisions: + (state.sliderMaxLength - state.sliderMinLength).round() > 0 + ? (state.sliderMaxLength - state.sliderMinLength) + .round() + : 1, label: state.length.round().toString(), onChanged: state.setLength, ), diff --git a/example/lib/widgets/strategy_controls_panel.dart b/example/lib/widgets/strategy_controls_panel.dart index 9b55018..863dfcc 100644 --- a/example/lib/widgets/strategy_controls_panel.dart +++ b/example/lib/widgets/strategy_controls_panel.dart @@ -1,14 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:password_engine/password_engine.dart'; import '../state/generator_state.dart'; -import '../strategies/custom_pin_strategy.dart'; -import '../strategies/memorable_password_strategy.dart'; -import '../strategies/pronounceable_password_strategy.dart'; -import 'strategies/custom_pin_strategy_controls.dart'; -import 'strategies/memorable_strategy_controls.dart'; -import 'strategies/pronounceable_strategy_controls.dart'; -import 'strategies/random_strategy_controls.dart'; class StrategyControlsPanel extends StatelessWidget { final GeneratorState state; @@ -43,24 +35,14 @@ class StrategyControlsPanel extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), - if (state.selectedStrategy is RandomPasswordStrategy) - RandomStrategyControls(state: state) - else if (state.selectedStrategy is PassphrasePasswordStrategy) - MemorableStrategyControls(state: state) - else if (state.selectedStrategy is MemorablePasswordStrategy) - MemorableStrategyControls(state: state) - else if (state.selectedStrategy is PronounceablePasswordStrategy) - PronounceableStrategyControls(state: state) - else if (state.selectedStrategy is CustomPinStrategy) - CustomPinStrategyControls( - state: state, prefixController: prefixController) - else - const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Text('No additional configuration required.'), + state.selectedStrategyConfig.controlBuilder + ?.call(state, prefixController) ?? + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No additional configuration required.'), + ), ), - ), ], ), ); diff --git a/example/pubspec.lock b/example/pubspec.lock index 424c93b..3abbfba 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -319,10 +319,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -483,10 +483,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" timing: dependency: transitive description: diff --git a/example/test/customize_character_sets_dialog_test.dart b/example/test/customize_character_sets_dialog_test.dart index 009a6a7..4099632 100644 --- a/example/test/customize_character_sets_dialog_test.dart +++ b/example/test/customize_character_sets_dialog_test.dart @@ -116,11 +116,6 @@ void main() { equals(CharacterSetProfile.defaultProfile.upperCaseLetters)); expect(savedProfile!.numbers, equals(CharacterSetProfile.defaultProfile.numbers)); - - // Verify text field updated to default (A-Z) - expect(find.text('XYZ'), findsNothing); - expect(find.text(CharacterSetProfile.defaultProfile.upperCaseLetters), - findsOneWidget); }); }); } diff --git a/example/test/password_options_test.dart b/example/test/password_options_test.dart index 18af9c9..d1f9835 100644 --- a/example/test/password_options_test.dart +++ b/example/test/password_options_test.dart @@ -35,7 +35,8 @@ void main() { await tester.tap(find.text('Memorable').last); await tester.pumpAndSettle(); - expect(state.selectedStrategy, isA()); + expect(state.selectedStrategyConfig.strategy, + isA()); expect(find.text('Memorable'), findsOneWidget); }); }); From d48481f824c392ef269a72f59ad224933d1fd75b Mon Sep 17 00:00:00 2001 From: Dhruvan Bhalara <53393418+dhruvanbhalara@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:13:21 +0530 Subject: [PATCH 2/4] refactor(example): extract _buildFilterChip to StrategyFilterChip StatelessWidget --- .../strategies/random_strategy_controls.dart | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/example/lib/widgets/strategies/random_strategy_controls.dart b/example/lib/widgets/strategies/random_strategy_controls.dart index 646a828..decb572 100644 --- a/example/lib/widgets/strategies/random_strategy_controls.dart +++ b/example/lib/widgets/strategies/random_strategy_controls.dart @@ -45,14 +45,34 @@ class RandomStrategyControls extends StatelessWidget { spacing: 12, runSpacing: 8, children: [ - _buildFilterChip(context, 'Uppercase', state.useUpperCase, - CharacterType.upper, const Key('checkbox_uppercase')), - _buildFilterChip(context, 'Lowercase', state.useLowerCase, - CharacterType.lower, const Key('checkbox_lowercase')), - _buildFilterChip(context, 'Numbers', state.useNumbers, - CharacterType.numbers, const Key('checkbox_numbers')), - _buildFilterChip(context, 'Symbols', state.useSpecialChars, - CharacterType.special, const Key('checkbox_special_chars')), + StrategyFilterChip( + label: 'Uppercase', + selected: state.useUpperCase, + type: CharacterType.upper, + key: const Key('checkbox_uppercase'), + state: state, + ), + StrategyFilterChip( + label: 'Lowercase', + selected: state.useLowerCase, + type: CharacterType.lower, + key: const Key('checkbox_lowercase'), + state: state, + ), + StrategyFilterChip( + label: 'Numbers', + selected: state.useNumbers, + type: CharacterType.numbers, + key: const Key('checkbox_numbers'), + state: state, + ), + StrategyFilterChip( + label: 'Symbols', + selected: state.useSpecialChars, + type: CharacterType.special, + key: const Key('checkbox_special_chars'), + state: state, + ), ], ), const SizedBox(height: 16), @@ -66,11 +86,25 @@ class RandomStrategyControls extends StatelessWidget { ], ); } +} + +class StrategyFilterChip extends StatelessWidget { + final String label; + final bool selected; + final CharacterType type; + final GeneratorState state; - Widget _buildFilterChip(BuildContext context, String label, bool selected, - CharacterType type, Key key) { + const StrategyFilterChip({ + super.key, + required this.label, + required this.selected, + required this.type, + required this.state, + }); + + @override + Widget build(BuildContext context) { return FilterChip( - key: key, label: Text(label), selected: selected, onSelected: (val) => state.setUseCase(val, type), From 38aec593de3c9704e93b370a4e4ed9aaad827726 Mon Sep 17 00:00:00 2001 From: Dhruvan Bhalara <53393418+dhruvanbhalara@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:14:54 +0530 Subject: [PATCH 3/4] refactor(example): extract _buildRequirementTile to PolicyRequirementTile StatelessWidget --- example/lib/widgets/policy_controls_card.dart | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/example/lib/widgets/policy_controls_card.dart b/example/lib/widgets/policy_controls_card.dart index 3dba45c..0f31b16 100644 --- a/example/lib/widgets/policy_controls_card.dart +++ b/example/lib/widgets/policy_controls_card.dart @@ -99,16 +99,36 @@ class PolicyControlsCard extends StatelessWidget { ], ), ), - _buildRequirementTile('Require Uppercase', - state.policyRequireUppercase, CharacterType.upper), - _buildRequirementTile('Require Lowercase', - state.policyRequireLowercase, CharacterType.lower), - _buildRequirementTile('Require Numbers', - state.policyRequireNumber, CharacterType.numbers), - _buildRequirementTile('Require Special Characters', - state.policyRequireSpecial, CharacterType.special), - _buildRequirementTile('Allow Spaces', state.policyAllowSpaces, - CharacterType.spaces), + PolicyRequirementTile( + title: 'Require Uppercase', + value: state.policyRequireUppercase, + type: CharacterType.upper, + state: state, + ), + PolicyRequirementTile( + title: 'Require Lowercase', + value: state.policyRequireLowercase, + type: CharacterType.lower, + state: state, + ), + PolicyRequirementTile( + title: 'Require Numbers', + value: state.policyRequireNumber, + type: CharacterType.numbers, + state: state, + ), + PolicyRequirementTile( + title: 'Require Special Characters', + value: state.policyRequireSpecial, + type: CharacterType.special, + state: state, + ), + PolicyRequirementTile( + title: 'Allow Spaces', + value: state.policyAllowSpaces, + type: CharacterType.spaces, + state: state, + ), ], ], const Divider(height: 1), @@ -127,8 +147,24 @@ class PolicyControlsCard extends StatelessWidget { ), ); } +} + +class PolicyRequirementTile extends StatelessWidget { + final String title; + final bool value; + final CharacterType type; + final GeneratorState state; - Widget _buildRequirementTile(String title, bool value, CharacterType type) { + const PolicyRequirementTile({ + super.key, + required this.title, + required this.value, + required this.type, + required this.state, + }); + + @override + Widget build(BuildContext context) { return CheckboxListTile( title: Text(title), value: value, From 8a1bc07da6d919c88b73fcca280fb88752dc9725 Mon Sep 17 00:00:00 2001 From: Dhruvan Bhalara <53393418+dhruvanbhalara@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:23:51 +0530 Subject: [PATCH 4/4] perf(example): isolate listenable builders to prevent full page rebuilds --- example/lib/main.dart | 123 +++++----- example/lib/state/generator_state.dart | 5 +- example/lib/widgets/password_display.dart | 202 ++++++++-------- example/lib/widgets/password_options.dart | 39 ++-- example/lib/widgets/policy_controls_card.dart | 216 +++++++++--------- .../lib/widgets/strategy_controls_panel.dart | 21 +- .../lib/widgets/strength_estimator_card.dart | 37 +-- example/test/password_display_test.dart | 94 ++++++-- example/test/widget_test.dart | 31 ++- 9 files changed, 429 insertions(+), 339 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 1296310..81dabb2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -123,75 +123,60 @@ class _PasswordExampleState extends State child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(18.0), - child: ListenableBuilder( - listenable: _state, - builder: (context, _) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AnimatedSection( - index: 0, - child: HeaderCard( - onCustomize: _showCustomizeDialog, - currentThemeMode: widget.currentThemeMode, - onThemeToggle: widget.onThemeToggle, - ), - ), - const SizedBox(height: 20), - AnimatedSection( - index: 1, - child: PasswordDisplay( - password: _state.isPasswordVisible - ? _state.password.value - : _state.password.masked, - strength: _state.strength, - feedback: _state.feedback, - fadeAnimation: _fadeAnimation, - estimatorLabel: _state.useZxcvbn - ? 'zxcvbn (direct)' - : 'Entropy (default)', - isVisible: _state.isPasswordVisible, - onVisibilityToggle: _state.togglePasswordVisibility, - ), - ), - const SizedBox(height: 16), - AnimatedSection( - index: 2, - child: ActionButtons( - onGenerate: _state.generatePassword, - onGenerateStrong: _state.generateStrongPassword, - onCopy: _handleCopyPassword, - ), - ), - const SizedBox(height: 24), - AnimatedSection( - index: 3, - child: PasswordOptions(state: _state), - ), - const SizedBox(height: 16), - AnimatedSection( - index: 4, - child: StrengthEstimatorCard( - useZxcvbn: _state.useZxcvbn, - onChanged: _state.toggleZxcvbn, - ), - ), - const SizedBox(height: 16), - AnimatedSection( - index: 5, - child: StrategyControlsPanel( - state: _state, - prefixController: _prefixController, - ), - ), - const SizedBox(height: 16), - AnimatedSection( - index: 6, - child: PolicyControlsCard(state: _state), - ), - ], - ); - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedSection( + index: 0, + child: HeaderCard( + onCustomize: _showCustomizeDialog, + currentThemeMode: widget.currentThemeMode, + onThemeToggle: widget.onThemeToggle, + ), + ), + const SizedBox(height: 20), + AnimatedSection( + index: 1, + child: PasswordDisplay( + state: _state, + fadeAnimation: _fadeAnimation, + ), + ), + const SizedBox(height: 16), + AnimatedSection( + index: 2, + child: ActionButtons( + onGenerate: _state.generatePassword, + onGenerateStrong: _state.generateStrongPassword, + onCopy: _handleCopyPassword, + ), + ), + const SizedBox(height: 24), + AnimatedSection( + index: 3, + child: PasswordOptions(state: _state), + ), + const SizedBox(height: 16), + AnimatedSection( + index: 4, + child: StrengthEstimatorCard( + state: _state, + ), + ), + const SizedBox(height: 16), + AnimatedSection( + index: 5, + child: StrategyControlsPanel( + state: _state, + prefixController: _prefixController, + ), + ), + const SizedBox(height: 16), + AnimatedSection( + index: 6, + child: PolicyControlsCard(state: _state), + ), + ], ), ), ), diff --git a/example/lib/state/generator_state.dart b/example/lib/state/generator_state.dart index 8deff5c..aa50b2b 100644 --- a/example/lib/state/generator_state.dart +++ b/example/lib/state/generator_state.dart @@ -37,8 +37,9 @@ class GeneratorState extends ChangeNotifier { _selectedStrategyConfig.name != 'Pronounceable') { return baseMin; // Word/PIN formats ignore strict char count policies visually. } - if (_useCorporatePolicy){ - return CustomCorporatePolicy.strictPolicy.minLength.toDouble();} + if (_useCorporatePolicy) { + return CustomCorporatePolicy.strictPolicy.minLength.toDouble(); + } if (_usePolicy && _policyMinLength > baseMin) return _policyMinLength; return baseMin; } diff --git a/example/lib/widgets/password_display.dart b/example/lib/widgets/password_display.dart index a19d982..4b42fc7 100644 --- a/example/lib/widgets/password_display.dart +++ b/example/lib/widgets/password_display.dart @@ -1,28 +1,18 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:password_engine/password_engine.dart'; +import '../state/generator_state.dart'; import 'password_strength_indicator.dart'; class PasswordDisplay extends StatelessWidget { - final String password; - final PasswordStrength strength; - final PasswordFeedback feedback; + final GeneratorState state; final Animation fadeAnimation; - final String estimatorLabel; - final bool isVisible; - final VoidCallback onVisibilityToggle; const PasswordDisplay({ super.key, - required this.password, - required this.strength, - required this.feedback, + required this.state, required this.fadeAnimation, - required this.estimatorLabel, - required this.isVisible, - required this.onVisibilityToggle, }); @override @@ -48,96 +38,114 @@ class PasswordDisplay extends StatelessWidget { ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Generated Password:', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 12), - FadeTransition( - opacity: fadeAnimation, - child: Row( - children: [ - Expanded( - child: SelectableText( - password, - key: const Key('password_display_text'), - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - IconButton( - icon: Icon( - isVisible ? Icons.visibility : Icons.visibility_off, - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onVisibilityToggle, - tooltip: isVisible ? 'Hide password' : 'Show password', - ), - ], - ), - ), - const SizedBox(height: 16), - PasswordStrengthIndicator(strength: strength), - const SizedBox(height: 10), - Text( - 'Estimator: $estimatorLabel', - style: Theme.of(context).textTheme.bodySmall, - ), - if (feedback.warning != null || - feedback.suggestions.isNotEmpty || - feedback.score != null || - feedback.estimatedEntropy != null) ...[ - const SizedBox(height: 16), - Text( - 'Feedback', - style: Theme.of(context).textTheme.titleSmall, - ), - if (feedback.warning != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - feedback.warning!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ), - if (feedback.suggestions.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: feedback.suggestions - .map((suggestion) => Chip(label: Text(suggestion))) - .toList(), - ), + child: ListenableBuilder( + listenable: state, + builder: (context, _) { + final password = state.isPasswordVisible + ? state.password.value + : state.password.masked; + final strength = state.strength; + final feedback = state.feedback; + final estimatorLabel = + state.useZxcvbn ? 'zxcvbn (direct)' : 'Entropy (default)'; + final isVisible = state.isPasswordVisible; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Generated Password:', + style: Theme.of(context).textTheme.titleLarge, ), - if (feedback.score != null || feedback.estimatedEntropy != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 16, + const SizedBox(height: 12), + FadeTransition( + opacity: fadeAnimation, + child: Row( children: [ - if (feedback.score != null) - Text('Score: ${feedback.score}/4'), - if (feedback.estimatedEntropy != null) - Text( - 'Entropy: ${feedback.estimatedEntropy!.toStringAsFixed(1)}', + Expanded( + child: SelectableText( + password, + key: const Key('password_display_text'), + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: Icon( + isVisible ? Icons.visibility : Icons.visibility_off, + color: Theme.of(context).colorScheme.primary, ), + onPressed: state.togglePasswordVisibility, + tooltip: + isVisible ? 'Hide password' : 'Show password', + ), ], ), ), - ], - ], + const SizedBox(height: 16), + PasswordStrengthIndicator(strength: strength), + const SizedBox(height: 10), + Text( + 'Estimator: $estimatorLabel', + style: Theme.of(context).textTheme.bodySmall, + ), + if (feedback.warning != null || + feedback.suggestions.isNotEmpty || + feedback.score != null || + feedback.estimatedEntropy != null) ...[ + const SizedBox(height: 16), + Text( + 'Feedback', + style: Theme.of(context).textTheme.titleSmall, + ), + if (feedback.warning != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + feedback.warning!, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + if (feedback.suggestions.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: feedback.suggestions + .map( + (suggestion) => Chip(label: Text(suggestion))) + .toList(), + ), + ), + if (feedback.score != null || + feedback.estimatedEntropy != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 16, + children: [ + if (feedback.score != null) + Text('Score: ${feedback.score}/4'), + if (feedback.estimatedEntropy != null) + Text( + 'Entropy: ${feedback.estimatedEntropy!.toStringAsFixed(1)}', + ), + ], + ), + ), + ], + ], + ); + }, ), ), ), diff --git a/example/lib/widgets/password_options.dart b/example/lib/widgets/password_options.dart index a4e138b..aee4ba6 100644 --- a/example/lib/widgets/password_options.dart +++ b/example/lib/widgets/password_options.dart @@ -40,23 +40,28 @@ class PasswordOptions extends StatelessWidget { labelText: 'Generation Strategy', border: OutlineInputBorder(), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - key: const Key('strategy_dropdown'), - value: state.selectedStrategyConfig, - isDense: true, - items: state.strategies.map((config) { - return DropdownMenuItem( - value: config, - child: Text(config.name), - ); - }).toList(), - onChanged: (config) { - if (config != null) { - state.setStrategyConfig(config); - } - }, - ), + child: ListenableBuilder( + listenable: state, + builder: (context, _) { + return DropdownButtonHideUnderline( + child: DropdownButton( + key: const Key('strategy_dropdown'), + value: state.selectedStrategyConfig, + isDense: true, + items: state.strategies.map((config) { + return DropdownMenuItem( + value: config, + child: Text(config.name), + ); + }).toList(), + onChanged: (config) { + if (config != null) { + state.setStrategyConfig(config); + } + }, + ), + ); + }, ), ), /* strategyControls logic has moved down to StrategyControlsPanel invoked from main.dart explicitly, so this widget only holds the dropdown */ diff --git a/example/lib/widgets/policy_controls_card.dart b/example/lib/widgets/policy_controls_card.dart index 0f31b16..cd7507f 100644 --- a/example/lib/widgets/policy_controls_card.dart +++ b/example/lib/widgets/policy_controls_card.dart @@ -34,114 +34,124 @@ class PolicyControlsCard extends StatelessWidget { ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SwitchListTile( - title: Text( - 'Enforce Password Policy', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - subtitle: const Text('Ensure passwords meet complexity rules'), - value: state.usePolicy, - onChanged: state.togglePolicyEnabled, - ), - if (state.usePolicy) ...[ - const Divider(height: 1), - SwitchListTile( - title: const Text('Use strict CustomCorporatePolicy'), - subtitle: const Text('Overrides manual policy rules'), - value: state.useCorporatePolicy, - onChanged: state.toggleCorporatePolicy, - ), - if (!state.useCorporatePolicy) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 16), - Text( - 'Minimum Length: ${state.policyMinLength.round()}', - style: Theme.of(context).textTheme.bodyMedium, - ), - Slider( - value: state.policyMinLength, - min: 8, - max: 64, - divisions: 56, - label: state.policyMinLength.round().toString(), - onChanged: state.setPolicyMinLength, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Enforce Maximum Length'), - value: state.usePolicyMaxLength, - onChanged: state.togglePolicyMaxLength, - ), - if (state.usePolicyMaxLength) ...[ - Text( - 'Maximum Length: ${state.policyMaxLength.round()}', - style: Theme.of(context).textTheme.bodyMedium, + child: ListenableBuilder( + listenable: state, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile( + title: Text( + 'Enforce Password Policy', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), - Slider( - value: state.policyMaxLength, - min: state.policyMinLength, - max: 128, - divisions: (128 - state.policyMinLength).round(), - label: state.policyMaxLength.round().toString(), - onChanged: state.setPolicyMaxLength, - ), - ], - const SizedBox(height: 8), - ], ), + subtitle: + const Text('Ensure passwords meet complexity rules'), + value: state.usePolicy, + onChanged: state.togglePolicyEnabled, ), - PolicyRequirementTile( - title: 'Require Uppercase', - value: state.policyRequireUppercase, - type: CharacterType.upper, - state: state, - ), - PolicyRequirementTile( - title: 'Require Lowercase', - value: state.policyRequireLowercase, - type: CharacterType.lower, - state: state, - ), - PolicyRequirementTile( - title: 'Require Numbers', - value: state.policyRequireNumber, - type: CharacterType.numbers, - state: state, - ), - PolicyRequirementTile( - title: 'Require Special Characters', - value: state.policyRequireSpecial, - type: CharacterType.special, - state: state, - ), - PolicyRequirementTile( - title: 'Allow Spaces', - value: state.policyAllowSpaces, - type: CharacterType.spaces, - state: state, + if (state.usePolicy) ...[ + const Divider(height: 1), + SwitchListTile( + title: const Text('Use strict CustomCorporatePolicy'), + subtitle: const Text('Overrides manual policy rules'), + value: state.useCorporatePolicy, + onChanged: state.toggleCorporatePolicy, + ), + if (!state.useCorporatePolicy) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + Text( + 'Minimum Length: ${state.policyMinLength.round()}', + style: Theme.of(context).textTheme.bodyMedium, + ), + Slider( + value: state.policyMinLength, + min: 8, + max: 64, + divisions: 56, + label: state.policyMinLength.round().toString(), + onChanged: state.setPolicyMinLength, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enforce Maximum Length'), + value: state.usePolicyMaxLength, + onChanged: state.togglePolicyMaxLength, + ), + if (state.usePolicyMaxLength) ...[ + Text( + 'Maximum Length: ${state.policyMaxLength.round()}', + style: Theme.of(context).textTheme.bodyMedium, + ), + Slider( + value: state.policyMaxLength, + min: state.policyMinLength, + max: 128, + divisions: + (128 - state.policyMinLength).round(), + label: state.policyMaxLength.round().toString(), + onChanged: state.setPolicyMaxLength, + ), + ], + const SizedBox(height: 8), + ], + ), + ), + PolicyRequirementTile( + title: 'Require Uppercase', + value: state.policyRequireUppercase, + type: CharacterType.upper, + state: state, + ), + PolicyRequirementTile( + title: 'Require Lowercase', + value: state.policyRequireLowercase, + type: CharacterType.lower, + state: state, + ), + PolicyRequirementTile( + title: 'Require Numbers', + value: state.policyRequireNumber, + type: CharacterType.numbers, + state: state, + ), + PolicyRequirementTile( + title: 'Require Special Characters', + value: state.policyRequireSpecial, + type: CharacterType.special, + state: state, + ), + PolicyRequirementTile( + title: 'Allow Spaces', + value: state.policyAllowSpaces, + type: CharacterType.spaces, + state: state, + ), + ], + ], + const Divider(height: 1), + SwitchListTile( + title: const Text('Enable Password Blocklist'), + subtitle: + const Text('Rejects common passwords like "123456"'), + value: state.useBlocklist, + onChanged: state.toggleBlocklist, + activeTrackColor: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.5), + activeThumbColor: Theme.of(context).colorScheme.error, ), ], - ], - const Divider(height: 1), - SwitchListTile( - title: const Text('Enable Password Blocklist'), - subtitle: const Text('Rejects common passwords like "123456"'), - value: state.useBlocklist, - onChanged: state.toggleBlocklist, - activeTrackColor: - Theme.of(context).colorScheme.error.withValues(alpha: 0.5), - activeThumbColor: Theme.of(context).colorScheme.error, - ), - ], + ); + }, ), ), ), diff --git a/example/lib/widgets/strategy_controls_panel.dart b/example/lib/widgets/strategy_controls_panel.dart index 863dfcc..0ee6a63 100644 --- a/example/lib/widgets/strategy_controls_panel.dart +++ b/example/lib/widgets/strategy_controls_panel.dart @@ -35,14 +35,19 @@ class StrategyControlsPanel extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), - state.selectedStrategyConfig.controlBuilder - ?.call(state, prefixController) ?? - const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Text('No additional configuration required.'), - ), - ), + ListenableBuilder( + listenable: state, + builder: (context, _) { + return state.selectedStrategyConfig.controlBuilder + ?.call(state, prefixController) ?? + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No additional configuration required.'), + ), + ); + }, + ), ], ), ); diff --git a/example/lib/widgets/strength_estimator_card.dart b/example/lib/widgets/strength_estimator_card.dart index a4c0b95..f35b942 100644 --- a/example/lib/widgets/strength_estimator_card.dart +++ b/example/lib/widgets/strength_estimator_card.dart @@ -1,18 +1,17 @@ import 'package:flutter/material.dart'; +import '../state/generator_state.dart'; + class StrengthEstimatorCard extends StatelessWidget { const StrengthEstimatorCard({ super.key, - required this.useZxcvbn, - required this.onChanged, + required this.state, }); - final bool useZxcvbn; - final ValueChanged onChanged; + final GeneratorState state; @override Widget build(BuildContext context) { - final estimatorLabel = useZxcvbn ? 'zxcvbn (direct)' : 'Entropy (default)'; return Card( elevation: 0, color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.95), @@ -27,14 +26,26 @@ class StrengthEstimatorCard extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), - Text('Active: $estimatorLabel'), - SwitchListTile( - key: const Key('toggle_zxcvbn'), - title: const Text('Use zxcvbn directly'), - subtitle: - const Text('More realistic scoring from the zxcvbn package.'), - value: useZxcvbn, - onChanged: onChanged, + ListenableBuilder( + listenable: state, + builder: (context, _) { + final estimatorLabel = + state.useZxcvbn ? 'zxcvbn (direct)' : 'Entropy (default)'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Active: $estimatorLabel'), + SwitchListTile( + key: const Key('toggle_zxcvbn'), + title: const Text('Use zxcvbn directly'), + subtitle: const Text( + 'More realistic scoring from the zxcvbn package.'), + value: state.useZxcvbn, + onChanged: state.toggleZxcvbn, + ), + ], + ); + }, ), ], ), diff --git a/example/test/password_display_test.dart b/example/test/password_display_test.dart index ecbe82a..f56c9fd 100644 --- a/example/test/password_display_test.dart +++ b/example/test/password_display_test.dart @@ -1,8 +1,54 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:password_engine/password_engine.dart'; +import 'package:password_engine_example/state/generator_state.dart'; import 'package:password_engine_example/widgets/password_display.dart'; +class MockGeneratorState extends ChangeNotifier implements GeneratorState { + PasswordStrength _strength = PasswordStrength.medium; + PasswordFeedback _feedback = + const PasswordFeedback(strength: PasswordStrength.medium); + String _passwordStr = 'password'; + bool _isVisible = true; + + void setMockState({ + required PasswordStrength strength, + required PasswordFeedback feedback, + required String password, + required bool isVisible, + }) { + _strength = strength; + _feedback = feedback; + _passwordStr = password; + _isVisible = isVisible; + notifyListeners(); + } + + @override + PasswordStrength get strength => _strength; + + @override + PasswordFeedback get feedback => _feedback; + + @override + bool get isPasswordVisible => _isVisible; + + @override + SensitivePassword get password => SensitivePassword(_passwordStr); + + @override + bool get useZxcvbn => false; + + @override + void togglePasswordVisibility() { + _isVisible = !_isVisible; + notifyListeners(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { group('PasswordDisplay', () { testWidgets('renders password and strength', (WidgetTester tester) async { @@ -14,19 +60,20 @@ void main() { Tween(begin: 0, end: 1).animate(animationController); animationController.forward(); + final state = MockGeneratorState() + ..setMockState( + password: 'test-password', + strength: PasswordStrength.strong, + feedback: const PasswordFeedback(strength: PasswordStrength.strong), + isVisible: true, + ); + await tester.pumpWidget( MaterialApp( home: Scaffold( body: PasswordDisplay( - password: 'test-password', - strength: PasswordStrength.strong, - feedback: const PasswordFeedback( - strength: PasswordStrength.strong, - ), + state: state, fadeAnimation: animation, - estimatorLabel: 'Entropy (default)', - isVisible: true, - onVisibilityToggle: () {}, ), ), ), @@ -35,14 +82,12 @@ void main() { expect(find.text('Generated Password:'), findsOneWidget); expect(find.text('test-password'), findsOneWidget); - expect(find.text('Strong'), - findsOneWidget); // Assuming Strength indicator shows text + expect(find.text('Strong'), findsOneWidget); expect(find.text('Estimator: Entropy (default)'), findsOneWidget); }); - testWidgets('renders feedback details when provided', ( - WidgetTester tester, - ) async { + testWidgets('renders feedback details when provided', + (WidgetTester tester) async { final animationController = AnimationController( vsync: const TestVSync(), duration: const Duration(milliseconds: 100), @@ -51,21 +96,24 @@ void main() { Tween(begin: 0, end: 1).animate(animationController); animationController.forward(); + final state = MockGeneratorState() + ..setMockState( + password: 'weak-pass', + strength: PasswordStrength.weak, + feedback: const PasswordFeedback( + strength: PasswordStrength.weak, + warning: 'Weak password', + suggestions: ['Add length', 'Add a number'], + ), + isVisible: true, + ); + await tester.pumpWidget( MaterialApp( home: Scaffold( body: PasswordDisplay( - password: 'weak-pass', - strength: PasswordStrength.weak, - feedback: const PasswordFeedback( - strength: PasswordStrength.weak, - warning: 'Weak password', - suggestions: ['Add length', 'Add a number'], - ), + state: state, fadeAnimation: animation, - estimatorLabel: 'Entropy (default)', - isVisible: true, - onVisibilityToggle: () {}, ), ), ), diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 5559800..7e90fba 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -121,7 +121,10 @@ void main() { expect( tester .widget( - find.byKey(const Key('checkbox_uppercase')), + find.descendant( + of: find.byKey(const Key('checkbox_uppercase')), + matching: find.byType(FilterChip), + ), ) .selected, isTrue, @@ -129,7 +132,10 @@ void main() { expect( tester .widget( - find.byKey(const Key('checkbox_lowercase')), + find.descendant( + of: find.byKey(const Key('checkbox_lowercase')), + matching: find.byType(FilterChip), + ), ) .selected, isTrue, @@ -137,7 +143,10 @@ void main() { expect( tester .widget( - find.byKey(const Key('checkbox_numbers')), + find.descendant( + of: find.byKey(const Key('checkbox_numbers')), + matching: find.byType(FilterChip), + ), ) .selected, isTrue, @@ -145,7 +154,10 @@ void main() { expect( tester .widget( - find.byKey(const Key('checkbox_special_chars')), + find.descendant( + of: find.byKey(const Key('checkbox_special_chars')), + matching: find.byType(FilterChip), + ), ) .selected, isTrue, @@ -189,11 +201,11 @@ void main() { await tester.tap(find.text('Random').last); await tester.pumpAndSettle(); - // Verify slider value was clamped to valid range (min 12 for Random) + // Verify slider value was clamped to valid range (min 12 for Random under default policy) final Slider sliderRandom = tester.widget( find.byKey(const Key('random_length_slider')), ); - expect(sliderRandom.value, greaterThanOrEqualTo(16.0)); + expect(sliderRandom.value, greaterThanOrEqualTo(12.0)); }); testWidgets('prevents deselecting all character types', ( @@ -231,7 +243,12 @@ void main() { await tester.pumpAndSettle(); // Verify Special Characters is still selected - final specialCharsWidget = tester.widget(specialCharsFinder); + final specialCharsWidget = tester.widget( + find.descendant( + of: specialCharsFinder, + matching: find.byType(FilterChip), + ), + ); expect( specialCharsWidget.selected, isTrue,