From 3c8661efd8ac5d4da518182afb5f7d3c5351d377 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Mon, 19 Aug 2024 16:23:53 +0200 Subject: [PATCH 01/10] feat: First iteration of SeedPhrasesViewer --- .../seed_phrase/seed_phrase_viewer.dart | 46 +++++++++++++ .../seed_phrase/seed_phrases_viewer.dart | 66 +++++++++++++++++++ catalyst_voices/lib/widgets/widgets.dart | 2 + .../examples/voices_seed_phrase_example.dart | 31 +++++++++ .../uikit_example/lib/examples_list.dart | 6 ++ 5 files changed, 151 insertions(+) create mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart create mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart create mode 100644 catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart new file mode 100644 index 00000000000..2e57e07f857 --- /dev/null +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart @@ -0,0 +1,46 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class SeedPhraseViewer extends StatelessWidget { + final int number; + final String word; + + const SeedPhraseViewer({ + super.key, + required this.number, + required this.word, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + constraints: const BoxConstraints(minHeight: 56), + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutralOpaqueLv1, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: DefaultTextStyle( + style: theme.textTheme.bodyLarge ?? const TextStyle(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${number.toString().padLeft(2, '0')}.', + style: TextStyle(color: theme.colors.textOnPrimary), + ), + const SizedBox(width: 6), + Text( + word, + style: TextStyle(color: theme.colors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart new file mode 100644 index 00000000000..64e4fd46d4f --- /dev/null +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart @@ -0,0 +1,66 @@ +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrase_viewer.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class SeedPhrasesViewer extends StatelessWidget { + final List words; + final int columnsCount; + + const SeedPhrasesViewer({ + super.key, + required this.words, + this.columnsCount = 2, + }); + + @override + Widget build(BuildContext context) { + final columnWordsCount = (words.length / columnsCount).ceil(); + final columns = words + .mapIndexed((index, element) => (index, element)) + .slices(columnWordsCount) + .toList(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns + .map((words) => _SeedPhrasesColumn(words: words)) + .map((e) => Expanded(child: e)) + .expandIndexed( + (index, element) => [ + if (index != 0) const SizedBox(width: 24), + element, + ], + ) + .toList(), + ); + } +} + +class _SeedPhrasesColumn extends StatelessWidget { + const _SeedPhrasesColumn({ + required this.words, + }); + + final List<(int, String)> words; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: words + .map((word) { + return SeedPhraseViewer( + number: word.$1 + 1, + word: word.$2, + ); + }) + .expandIndexed( + (index, element) => [ + if (index != 0) const SizedBox(height: 12), + element, + ], + ) + .toList(), + ); + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 89007b098de..7fbc1ff023d 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -9,6 +9,8 @@ export 'indicators/process_progress_indicator.dart'; export 'indicators/voices_circular_progress_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_status_indicator.dart'; +export 'seed_phrase/seed_phrase_viewer.dart'; +export 'seed_phrase/seed_phrases_viewer.dart'; export 'separators/voices_divider.dart'; export 'separators/voices_text_divider.dart'; export 'separators/voices_vertical_divider.dart'; diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart new file mode 100644 index 00000000000..146d61bd6b3 --- /dev/null +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:flutter/material.dart'; + +class VoicesSeedPhraseExample extends StatelessWidget { + static const String route = '/seed-phrase-example'; + + const VoicesSeedPhraseExample({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Seed Phrase')), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + children: [ + SeedPhrasesViewer( + words: [ + 'real', + 'mission', + 'secure', + 'renew', + 'renew renew renew', + ], + ), + ], + ), + ); + } +} diff --git a/catalyst_voices/uikit_example/lib/examples_list.dart b/catalyst_voices/uikit_example/lib/examples_list.dart index 974add48427..4bda4b4d363 100644 --- a/catalyst_voices/uikit_example/lib/examples_list.dart +++ b/catalyst_voices/uikit_example/lib/examples_list.dart @@ -11,6 +11,7 @@ import 'package:uikit_example/examples/voices_fab_example.dart'; import 'package:uikit_example/examples/voices_indicators_example.dart'; import 'package:uikit_example/examples/voices_navigation_example.dart'; import 'package:uikit_example/examples/voices_radio_example.dart'; +import 'package:uikit_example/examples/voices_seed_phrase_example.dart'; import 'package:uikit_example/examples/voices_segmented_button_example.dart'; import 'package:uikit_example/examples/voices_separators_example.dart'; import 'package:uikit_example/examples/voices_snackbar_example.dart'; @@ -90,6 +91,11 @@ class ExamplesListPage extends StatelessWidget { route: VoicesFabExample.route, page: VoicesFabExample(), ), + ExampleTile( + title: 'Voices Seed Phrase', + route: VoicesSeedPhraseExample.route, + page: VoicesSeedPhraseExample(), + ), ]; } From 58d4d6316ee35aa6c9d5de5c973faba1d8a4c66c Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 08:44:33 +0200 Subject: [PATCH 02/10] refactor: Extract ColumnsRow widget --- .../lib/widgets/common/columns_row.dart | 62 +++++++++++++++++++ .../seed_phrase/seed_phrases_viewer.dart | 59 ++++-------------- 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 catalyst_voices/lib/widgets/common/columns_row.dart diff --git a/catalyst_voices/lib/widgets/common/columns_row.dart b/catalyst_voices/lib/widgets/common/columns_row.dart new file mode 100644 index 00000000000..935a69b31c4 --- /dev/null +++ b/catalyst_voices/lib/widgets/common/columns_row.dart @@ -0,0 +1,62 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class ColumnsRow extends StatelessWidget { + final int columnsCount; + final double mainAxisSpacing; + final double crossAxisSpacing; + final List children; + + const ColumnsRow({ + super.key, + required this.columnsCount, + this.mainAxisSpacing = 0.0, + this.crossAxisSpacing = 0.0, + required this.children, + }); + + @override + Widget build(BuildContext context) { + final columnCount = (children.length / columnsCount).ceil(); + final columns = children.slices(columnCount).toList(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns + .map((e) => _Column(spacing: crossAxisSpacing, children: e)) + .map((e) => Expanded(child: e)) + .expandIndexed( + (index, element) => [ + if (index != 0) SizedBox(width: mainAxisSpacing), + element, + ], + ) + .toList(), + ); + } +} + +class _Column extends StatelessWidget { + final double spacing; + final List children; + + const _Column({ + this.spacing = 0.0, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children + .expandIndexed( + (index, element) => [ + if (index != 0) SizedBox(height: spacing), + element, + ], + ) + .toList(), + ); + } +} diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart index 64e4fd46d4f..f836261c1b9 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/columns_row.dart'; import 'package:catalyst_voices/widgets/seed_phrase/seed_phrase_viewer.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -14,53 +15,17 @@ class SeedPhrasesViewer extends StatelessWidget { @override Widget build(BuildContext context) { - final columnWordsCount = (words.length / columnsCount).ceil(); - final columns = words - .mapIndexed((index, element) => (index, element)) - .slices(columnWordsCount) - .toList(); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: columns - .map((words) => _SeedPhrasesColumn(words: words)) - .map((e) => Expanded(child: e)) - .expandIndexed( - (index, element) => [ - if (index != 0) const SizedBox(width: 24), - element, - ], - ) - .toList(), - ); - } -} - -class _SeedPhrasesColumn extends StatelessWidget { - const _SeedPhrasesColumn({ - required this.words, - }); - - final List<(int, String)> words; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: words - .map((word) { - return SeedPhraseViewer( - number: word.$1 + 1, - word: word.$2, - ); - }) - .expandIndexed( - (index, element) => [ - if (index != 0) const SizedBox(height: 12), - element, - ], - ) - .toList(), + return ColumnsRow( + columnsCount: 2, + mainAxisSpacing: 24, + crossAxisSpacing: 12, + children: words.mapIndexed((index, word) { + return SeedPhraseViewer( + key: ValueKey('SeedPhrase${word}CellKey'), + number: index + 1, + word: word, + ); + }).toList(), ); } } From 010ff76de869f59ddf289a5172927505b64259ea Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 08:48:36 +0200 Subject: [PATCH 03/10] fix: word overflow, update exemaple --- .../lib/widgets/seed_phrase/seed_phrase_viewer.dart | 12 +++++++----- .../lib/examples/voices_seed_phrase_example.dart | 10 +++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart index 2e57e07f857..8c9e1ac5e09 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart @@ -32,11 +32,13 @@ class SeedPhraseViewer extends StatelessWidget { style: TextStyle(color: theme.colors.textOnPrimary), ), const SizedBox(width: 6), - Text( - word, - style: TextStyle(color: theme.colors.textPrimary), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Flexible( + child: Text( + word, + style: TextStyle(color: theme.colors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart index 146d61bd6b3..010e56bd31f 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -15,15 +15,23 @@ class VoicesSeedPhraseExample extends StatelessWidget { body: ListView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), children: [ + Text('SeedPhrasesViewer'), + SizedBox(height: 12), SeedPhrasesViewer( words: [ 'real', 'mission', 'secure', 'renew', - 'renew renew renew', + 'key', + 'audit', + 'right', + 'gas', ], ), + SizedBox(height: 24), + Text('SeedPhrasesEditor'), + SizedBox(height: 12), ], ), ); From 55b24b6026446775e2ed9288650101ae685c7af2 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 11:32:41 +0200 Subject: [PATCH 04/10] feat: SeedPhrases Picker and Completer --- .../seed_phrase/seed_phrase_viewer.dart | 48 ----- .../seed_phrase/seed_phrases_completer.dart | 180 ++++++++++++++++++ .../seed_phrase/seed_phrases_picker.dart | 114 +++++++++++ .../seed_phrase/seed_phrases_viewer.dart | 76 ++++++-- catalyst_voices/lib/widgets/widgets.dart | 3 +- .../examples/voices_seed_phrase_example.dart | 64 +++++-- 6 files changed, 405 insertions(+), 80 deletions(-) delete mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart create mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart create mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart deleted file mode 100644 index 8c9e1ac5e09..00000000000 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrase_viewer.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; - -class SeedPhraseViewer extends StatelessWidget { - final int number; - final String word; - - const SeedPhraseViewer({ - super.key, - required this.number, - required this.word, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - constraints: const BoxConstraints(minHeight: 56), - decoration: BoxDecoration( - color: theme.colors.onSurfaceNeutralOpaqueLv1, - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), - child: DefaultTextStyle( - style: theme.textTheme.bodyLarge ?? const TextStyle(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${number.toString().padLeft(2, '0')}.', - style: TextStyle(color: theme.colors.textOnPrimary), - ), - const SizedBox(width: 6), - Flexible( - child: Text( - word, - style: TextStyle(color: theme.colors.textPrimary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart new file mode 100644 index 00000000000..5a54f4edb59 --- /dev/null +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart @@ -0,0 +1,180 @@ +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; +import 'package:catalyst_voices/widgets/common/columns_row.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class SeedPhrasesCompleter extends StatelessWidget { + final int columnsCount; + final int slotsCount; + final Set words; + final ValueChanged? onWordDeleteTap; + + const SeedPhrasesCompleter({ + super.key, + this.columnsCount = 2, + required this.slotsCount, + this.words = const {}, + this.onWordDeleteTap, + }); + + @override + Widget build(BuildContext context) { + final slots = List.generate(slotsCount, words.elementAtOrNull); + final onWordDeleteTap = this.onWordDeleteTap; + + // If has less words then slots then next empty slot is "current". + // Null when has all words completed + final currentIndex = words.length < slotsCount ? words.length : null; + + return MediaQuery.withNoTextScaling( + child: ColumnsRow( + columnsCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 6, + children: slots.mapIndexed((index, element) { + final isCurrent = index == currentIndex; + final isPrevious = currentIndex != null + ? currentIndex == index + 1 + : index == slotsCount - 1; + + final canDelete = element != null && isPrevious; + + return _WordSlotCell( + element, + key: ValueKey('CompleterSeedPhrase${index}CellKey'), + slotNr: index + 1, + isActive: isCurrent, + showDelete: canDelete, + onTap: !canDelete || onWordDeleteTap == null + ? null + : () { + onWordDeleteTap(element); + }, + ); + }).toList(), + ), + ); + } +} + +class _WordSlotCell extends StatelessWidget { + final String? data; + final int slotNr; + final bool isActive; + final bool showDelete; + final VoidCallback? onTap; + + Set get states => { + if (data != null) WidgetState.selected, + if (isActive) WidgetState.focused, + if (data == null && !isActive) WidgetState.disabled, + }; + + const _WordSlotCell( + this.data, { + super.key, + required this.slotNr, + this.isActive = false, + this.showDelete = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final backgroundColor = _CellBackgroundColor(theme); + final foregroundColor = _CellForegroundColor(theme); + final border = _CellBorder(theme); + + return AnimatedContainer( + duration: kThemeChangeDuration, + constraints: const BoxConstraints.tightFor(height: 32), + decoration: BoxDecoration( + color: backgroundColor.resolve(states), + border: border.resolve(states), + borderRadius: BorderRadius.circular(8), + ), + child: Material( + type: MaterialType.transparency, + textStyle: (theme.textTheme.labelLarge ?? const TextStyle()) + .copyWith(color: foregroundColor.resolve(states)), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Center( + child: AffixDecorator( + gap: showDelete ? 8 : 0, + suffix: Offstage( + offstage: !showDelete, + child: const Icon(Icons.close), + ), + iconTheme: IconThemeData( + color: foregroundColor.resolve(states), + size: 18, + ), + // TODO(damian): loc + child: Text(data ?? 'Slot $slotNr'), + ), + ), + ), + ), + ); + } +} + +final class _CellBackgroundColor extends WidgetStateProperty { + final ThemeData theme; + + _CellBackgroundColor(this.theme); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return theme.colorScheme.onSurface.withOpacity(0.08); + } + + if (states.contains(WidgetState.focused)) { + return theme.colors.onSurfaceNeutralOpaqueLv0; + } + + return theme.colorScheme.primary; + } +} + +final class _CellForegroundColor extends WidgetStateProperty { + final ThemeData theme; + + _CellForegroundColor(this.theme); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return theme.colors.textDisabled; + } + + if (states.contains(WidgetState.focused)) { + return theme.colorScheme.primary; + } + + return theme.colorScheme.onPrimary; + } +} + +final class _CellBorder extends WidgetStateProperty { + final ThemeData theme; + + _CellBorder(this.theme); + + @override + BoxBorder? resolve(Set states) { + if (states.contains(WidgetState.focused)) { + return Border.all( + color: theme.colorScheme.primary, + ); + } + + return null; + } +} diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart new file mode 100644 index 00000000000..785ad957499 --- /dev/null +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart @@ -0,0 +1,114 @@ +import 'package:catalyst_voices/widgets/common/columns_row.dart'; +import 'package:flutter/material.dart'; + +class SeedPhrasesPicker extends StatelessWidget { + final int columnsCount; + final List words; + final Set selectedWords; + final ValueChanged? onWordTap; + + const SeedPhrasesPicker({ + super.key, + this.columnsCount = 2, + required this.words, + this.selectedWords = const {}, + this.onWordTap, + }); + + @override + Widget build(BuildContext context) { + final onWordTap = this.onWordTap; + + return MediaQuery.withNoTextScaling( + child: ColumnsRow( + columnsCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 6, + children: words.map((word) { + final isSelected = selectedWords.contains(word); + + return _WordCell( + word, + key: ValueKey('PickerSeedPhrase${word}CellKey'), + isSelected: isSelected, + onTap: isSelected || onWordTap == null + ? null + : () { + onWordTap(word); + }, + ); + }).toList(), + ), + ); + } +} + +class _WordCell extends StatelessWidget { + final String data; + final bool isSelected; + final VoidCallback? onTap; + + Set get states => { + if (isSelected) WidgetState.selected, + }; + + const _WordCell( + this.data, { + super.key, + this.isSelected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final backgroundColor = _CellBackgroundColor(theme); + final foregroundColor = _CellForegroundColor(theme); + + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 32), + child: Material( + color: backgroundColor.resolve(states), + borderRadius: BorderRadius.circular(8), + textStyle: theme.textTheme.labelLarge + ?.copyWith(color: foregroundColor.resolve(states)), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Center(child: Text(data)), + ), + ), + ); + } +} + +final class _CellBackgroundColor extends WidgetStateProperty { + final ThemeData theme; + + _CellBackgroundColor(this.theme); + + @override + Color resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return theme.colorScheme.primary.withOpacity(0.12); + } + + return theme.colorScheme.primary; + } +} + +final class _CellForegroundColor extends WidgetStateProperty { + final ThemeData theme; + + _CellForegroundColor(this.theme); + + @override + Color resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return theme.colorScheme.primary; + } + + return theme.colorScheme.onPrimary; + } +} diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart index f836261c1b9..3e9a648596f 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart @@ -1,31 +1,79 @@ import 'package:catalyst_voices/widgets/common/columns_row.dart'; -import 'package:catalyst_voices/widgets/seed_phrase/seed_phrase_viewer.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class SeedPhrasesViewer extends StatelessWidget { - final List words; final int columnsCount; + final List words; const SeedPhrasesViewer({ super.key, - required this.words, this.columnsCount = 2, + required this.words, }); @override Widget build(BuildContext context) { - return ColumnsRow( - columnsCount: 2, - mainAxisSpacing: 24, - crossAxisSpacing: 12, - children: words.mapIndexed((index, word) { - return SeedPhraseViewer( - key: ValueKey('SeedPhrase${word}CellKey'), - number: index + 1, - word: word, - ); - }).toList(), + return MediaQuery.withNoTextScaling( + child: ColumnsRow( + columnsCount: 2, + mainAxisSpacing: 24, + crossAxisSpacing: 12, + children: words.mapIndexed((index, word) { + return _WordCell( + word, + key: ValueKey('SeedPhrase${word}CellKey'), + number: index + 1, + ); + }).toList(), + ), + ); + } +} + +class _WordCell extends StatelessWidget { + final int number; + final String data; + + const _WordCell( + this.data, { + super.key, + required this.number, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + constraints: const BoxConstraints(minHeight: 56), + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutralOpaqueLv1, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: DefaultTextStyle( + style: theme.textTheme.bodyLarge ?? const TextStyle(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${number.toString().padLeft(2, '0')}.', + style: TextStyle(color: theme.colors.textOnPrimary), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + data, + style: TextStyle(color: theme.colors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ); } } diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 7fbc1ff023d..7e478f9385e 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -9,7 +9,8 @@ export 'indicators/process_progress_indicator.dart'; export 'indicators/voices_circular_progress_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_status_indicator.dart'; -export 'seed_phrase/seed_phrase_viewer.dart'; +export 'seed_phrase/seed_phrases_completer.dart'; +export 'seed_phrase/seed_phrases_picker.dart'; export 'seed_phrase/seed_phrases_viewer.dart'; export 'separators/voices_divider.dart'; export 'separators/voices_text_divider.dart'; diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart index 010e56bd31f..c9c4284cc42 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -1,13 +1,33 @@ import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:flutter/material.dart'; -class VoicesSeedPhraseExample extends StatelessWidget { +class VoicesSeedPhraseExample extends StatefulWidget { static const String route = '/seed-phrase-example'; + static const _words = [ + 'real', + 'mission', + 'secure', + 'renew', + 'key', + 'audit', + 'right', + 'gas', + ]; + const VoicesSeedPhraseExample({ super.key, }); + @override + State createState() { + return _VoicesSeedPhraseExampleState(); + } +} + +class _VoicesSeedPhraseExampleState extends State { + final _selectedWords = {}; + @override Widget build(BuildContext context) { return Scaffold( @@ -15,23 +35,33 @@ class VoicesSeedPhraseExample extends StatelessWidget { body: ListView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), children: [ - Text('SeedPhrasesViewer'), - SizedBox(height: 12), - SeedPhrasesViewer( - words: [ - 'real', - 'mission', - 'secure', - 'renew', - 'key', - 'audit', - 'right', - 'gas', - ], + const Text('SeedPhrasesViewer'), + const SizedBox(height: 12), + const SeedPhrasesViewer(words: VoicesSeedPhraseExample._words), + const SizedBox(height: 24), + const Text('SeedPhrasesPicker'), + const SizedBox(height: 12), + SeedPhrasesPicker( + words: VoicesSeedPhraseExample._words, + selectedWords: _selectedWords, + onWordTap: (value) { + setState(() { + _selectedWords.add(value); + }); + }, + ), + const SizedBox(height: 24), + const Text('SeedPhrasesCompleter'), + const SizedBox(height: 12), + SeedPhrasesCompleter( + slotsCount: VoicesSeedPhraseExample._words.length, + words: _selectedWords, + onWordDeleteTap: (value) { + setState(() { + _selectedWords.remove(value); + }); + }, ), - SizedBox(height: 24), - Text('SeedPhrasesEditor'), - SizedBox(height: 12), ], ), ); From bbac4765ec5cc95a6e9a6f922f90868bf4a14867 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 11:56:04 +0200 Subject: [PATCH 05/10] feat: SeedPhrasesEditor --- .../seed_phrase/seed_phrases_completer.dart | 10 +- .../seed_phrase/seed_phrases_editor.dart | 95 +++++++++++++++++++ catalyst_voices/lib/widgets/widgets.dart | 1 + .../examples/voices_seed_phrase_example.dart | 9 +- 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart index 5a54f4edb59..9578dc8e349 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart @@ -8,20 +8,20 @@ class SeedPhrasesCompleter extends StatelessWidget { final int columnsCount; final int slotsCount; final Set words; - final ValueChanged? onWordDeleteTap; + final ValueChanged? onWordTap; const SeedPhrasesCompleter({ super.key, this.columnsCount = 2, required this.slotsCount, this.words = const {}, - this.onWordDeleteTap, + this.onWordTap, }); @override Widget build(BuildContext context) { final slots = List.generate(slotsCount, words.elementAtOrNull); - final onWordDeleteTap = this.onWordDeleteTap; + final onWordTap = this.onWordTap; // If has less words then slots then next empty slot is "current". // Null when has all words completed @@ -46,10 +46,10 @@ class SeedPhrasesCompleter extends StatelessWidget { slotNr: index + 1, isActive: isCurrent, showDelete: canDelete, - onTap: !canDelete || onWordDeleteTap == null + onTap: !canDelete || onWordTap == null ? null : () { - onWordDeleteTap(element); + onWordTap(element); }, ); }).toList(), diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart new file mode 100644 index 00000000000..bbf3b6b2184 --- /dev/null +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart @@ -0,0 +1,95 @@ +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_completer.dart'; +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_picker.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class SeedPhrasesEditor extends StatefulWidget { + final List words; + final ValueChanged> onChanged; + + const SeedPhrasesEditor({ + super.key, + required this.words, + required this.onChanged, + }); + + @override + State createState() => _SeedPhrasesEditorState(); +} + +class _SeedPhrasesEditorState extends State { + final _selected = {}; + + @override + void didUpdateWidget(covariant SeedPhrasesEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!listEquals(widget.words, oldWidget.words)) { + _selected.clear(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _BorderDecorator( + child: SeedPhrasesCompleter( + slotsCount: widget.words.length, + words: _selected, + onWordTap: _removeWord, + ), + ), + const SizedBox(height: 12), + _BorderDecorator( + child: SeedPhrasesPicker( + words: widget.words, + selectedWords: _selected, + onWordTap: _selectWord, + ), + ), + ], + ); + } + + void _removeWord(String word) { + setState(() { + _selected.remove(word); + widget.onChanged(Set.of(_selected)); + }); + } + + void _selectWord(String word) { + setState(() { + _selected.add(word); + widget.onChanged(Set.of(_selected)); + }); + } +} + +class _BorderDecorator extends StatelessWidget { + final Widget child; + + const _BorderDecorator({ + required this.child, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colors.outlineBorderVariant!, + width: 1.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: child, + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 7e478f9385e..3d6a7fa726d 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -10,6 +10,7 @@ export 'indicators/voices_circular_progress_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_status_indicator.dart'; export 'seed_phrase/seed_phrases_completer.dart'; +export 'seed_phrase/seed_phrases_editor.dart'; export 'seed_phrase/seed_phrases_picker.dart'; export 'seed_phrase/seed_phrases_viewer.dart'; export 'separators/voices_divider.dart'; diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart index c9c4284cc42..793cb17e4be 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -39,6 +39,13 @@ class _VoicesSeedPhraseExampleState extends State { const SizedBox(height: 12), const SeedPhrasesViewer(words: VoicesSeedPhraseExample._words), const SizedBox(height: 24), + const Text('SeedPhrasesEditor'), + const SizedBox(height: 12), + SeedPhrasesEditor( + words: VoicesSeedPhraseExample._words, + onChanged: (value) {}, + ), + const SizedBox(height: 24), const Text('SeedPhrasesPicker'), const SizedBox(height: 12), SeedPhrasesPicker( @@ -56,7 +63,7 @@ class _VoicesSeedPhraseExampleState extends State { SeedPhrasesCompleter( slotsCount: VoicesSeedPhraseExample._words.length, words: _selectedWords, - onWordDeleteTap: (value) { + onWordTap: (value) { setState(() { _selectedWords.remove(value); }); From c057d3423baebb7b444cc514869e471472e7036e Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 12:06:27 +0200 Subject: [PATCH 06/10] refactor: Rename SeedPhrasesEditor to SeedPhrasesSequencer --- ...ditor.dart => seed_phrases_sequencer.dart} | 10 ++-- catalyst_voices/lib/widgets/widgets.dart | 2 +- .../examples/voices_seed_phrase_example.dart | 51 +++++-------------- 3 files changed, 19 insertions(+), 44 deletions(-) rename catalyst_voices/lib/widgets/seed_phrase/{seed_phrases_editor.dart => seed_phrases_sequencer.dart} (87%) diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart similarity index 87% rename from catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart rename to catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart index bbf3b6b2184..3d3ef3e9c4e 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_editor.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart @@ -4,25 +4,25 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class SeedPhrasesEditor extends StatefulWidget { +class SeedPhrasesSequencer extends StatefulWidget { final List words; final ValueChanged> onChanged; - const SeedPhrasesEditor({ + const SeedPhrasesSequencer({ super.key, required this.words, required this.onChanged, }); @override - State createState() => _SeedPhrasesEditorState(); + State createState() => _SeedPhrasesSequencerState(); } -class _SeedPhrasesEditorState extends State { +class _SeedPhrasesSequencerState extends State { final _selected = {}; @override - void didUpdateWidget(covariant SeedPhrasesEditor oldWidget) { + void didUpdateWidget(covariant SeedPhrasesSequencer oldWidget) { super.didUpdateWidget(oldWidget); if (!listEquals(widget.words, oldWidget.words)) { diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 3d6a7fa726d..354de826c42 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -10,8 +10,8 @@ export 'indicators/voices_circular_progress_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_status_indicator.dart'; export 'seed_phrase/seed_phrases_completer.dart'; -export 'seed_phrase/seed_phrases_editor.dart'; export 'seed_phrase/seed_phrases_picker.dart'; +export 'seed_phrase/seed_phrases_sequencer.dart'; export 'seed_phrase/seed_phrases_viewer.dart'; export 'separators/voices_divider.dart'; export 'separators/voices_text_divider.dart'; diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart index 793cb17e4be..ea90291632a 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -1,7 +1,7 @@ import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:flutter/material.dart'; -class VoicesSeedPhraseExample extends StatefulWidget { +class VoicesSeedPhraseExample extends StatelessWidget { static const String route = '/seed-phrase-example'; static const _words = [ @@ -13,21 +13,20 @@ class VoicesSeedPhraseExample extends StatefulWidget { 'audit', 'right', 'gas', + 'key', + 'secure', + 'win', + 'review', + 'car', + 'sand', + 'real', + 'house', ]; const VoicesSeedPhraseExample({ super.key, }); - @override - State createState() { - return _VoicesSeedPhraseExampleState(); - } -} - -class _VoicesSeedPhraseExampleState extends State { - final _selectedWords = {}; - @override Widget build(BuildContext context) { return Scaffold( @@ -37,38 +36,14 @@ class _VoicesSeedPhraseExampleState extends State { children: [ const Text('SeedPhrasesViewer'), const SizedBox(height: 12), - const SeedPhrasesViewer(words: VoicesSeedPhraseExample._words), + const SeedPhrasesViewer(words: _words), const SizedBox(height: 24), - const Text('SeedPhrasesEditor'), + const Text('SeedPhrasesSequencer'), const SizedBox(height: 12), - SeedPhrasesEditor( - words: VoicesSeedPhraseExample._words, + SeedPhrasesSequencer( + words: _words, onChanged: (value) {}, ), - const SizedBox(height: 24), - const Text('SeedPhrasesPicker'), - const SizedBox(height: 12), - SeedPhrasesPicker( - words: VoicesSeedPhraseExample._words, - selectedWords: _selectedWords, - onWordTap: (value) { - setState(() { - _selectedWords.add(value); - }); - }, - ), - const SizedBox(height: 24), - const Text('SeedPhrasesCompleter'), - const SizedBox(height: 12), - SeedPhrasesCompleter( - slotsCount: VoicesSeedPhraseExample._words.length, - words: _selectedWords, - onWordTap: (value) { - setState(() { - _selectedWords.remove(value); - }); - }, - ), ], ), ); From 46f616cbdfdb66df57358533eae5d545d2a29c2b Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 13:09:34 +0200 Subject: [PATCH 07/10] fix: clean up example seed words --- .../lib/examples/voices_seed_phrase_example.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart index ea90291632a..f724d451285 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_seed_phrase_example.dart @@ -13,14 +13,10 @@ class VoicesSeedPhraseExample extends StatelessWidget { 'audit', 'right', 'gas', - 'key', - 'secure', - 'win', - 'review', + 'house', + 'plant', 'car', 'sand', - 'real', - 'house', ]; const VoicesSeedPhraseExample({ From 36aa1bff0957a84402fc86c59841257ae145a925 Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 14:55:49 +0200 Subject: [PATCH 08/10] chore: widgets docs --- .../lib/widgets/common/columns_row.dart | 28 +++++++++++++++- .../seed_phrase/seed_phrases_completer.dart | 33 ++++++++++++++++--- .../seed_phrase/seed_phrases_picker.dart | 19 +++++++++++ .../seed_phrase/seed_phrases_sequencer.dart | 12 +++++++ .../seed_phrase/seed_phrases_viewer.dart | 13 +++++++- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/catalyst_voices/lib/widgets/common/columns_row.dart b/catalyst_voices/lib/widgets/common/columns_row.dart index 935a69b31c4..e26b0cb85a2 100644 --- a/catalyst_voices/lib/widgets/common/columns_row.dart +++ b/catalyst_voices/lib/widgets/common/columns_row.dart @@ -1,19 +1,44 @@ +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_completer.dart'; +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_picker.dart'; +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_viewer.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +/// A widget that arranges children into columns within a row. +/// +/// This widget divides its children into columns based on the [columnsCount] +/// property. Each column is then laid out vertically with optional spacing +/// between children. +/// +/// The [mainAxisSpacing] property controls the horizontal spacing between +/// columns, while the [crossAxisSpacing] property controls the vertical +/// spacing within each column. +/// +/// See following widgets for examples of usage. +/// - [SeedPhrasesViewer], [SeedPhrasesPicker] and [SeedPhrasesCompleter] class ColumnsRow extends StatelessWidget { + /// The number of columns to create. final int columnsCount; + + /// The horizontal spacing between columns. Defaults to 0.0. final double mainAxisSpacing; + + /// The vertical spacing between children within each column. Defaults to 0.0. final double crossAxisSpacing; + + /// The children to be arranged in columns. final List children; + /// Creates a [ColumnsRow] widget. + /// + /// The [columnsCount] argument must be positive. const ColumnsRow({ super.key, required this.columnsCount, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, required this.children, - }); + }) : assert(columnsCount >= 0, 'Columns count can not be zero or negative'); @override Widget build(BuildContext context) { @@ -36,6 +61,7 @@ class ColumnsRow extends StatelessWidget { } } +/// A helper widget that arranges children vertically with optional spacing. class _Column extends StatelessWidget { final double spacing; final List children; diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart index 9578dc8e349..6d0a7ba6504 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart @@ -1,15 +1,29 @@ import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; import 'package:catalyst_voices/widgets/common/columns_row.dart'; +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_picker.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +/// A widget that displays slots for selecting seed phrases and filling them up. +/// +/// Typically used together with a [SeedPhrasesPicker]. class SeedPhrasesCompleter extends StatelessWidget { + /// The number of columns to use for displaying the slots. + /// Defaults to 2. final int columnsCount; + + /// The total number of slots available for seed phrases. final int slotsCount; + + /// A set of currently selected seed phrases. Defaults to an empty set. final Set words; + + /// A callback function triggered when a non-filled slot or a filled but only + /// for previous selection. final ValueChanged? onWordTap; + /// Creates a [SeedPhrasesCompleter] widget. const SeedPhrasesCompleter({ super.key, this.columnsCount = 2, @@ -23,8 +37,8 @@ class SeedPhrasesCompleter extends StatelessWidget { final slots = List.generate(slotsCount, words.elementAtOrNull); final onWordTap = this.onWordTap; - // If has less words then slots then next empty slot is "current". - // Null when has all words completed + // Identify the currently active slot (being filled) and the + // previous slot (deletable). final currentIndex = words.length < slotsCount ? words.length : null; return MediaQuery.withNoTextScaling( @@ -48,9 +62,7 @@ class SeedPhrasesCompleter extends StatelessWidget { showDelete: canDelete, onTap: !canDelete || onWordTap == null ? null - : () { - onWordTap(element); - }, + : () => onWordTap(element), ); }).toList(), ), @@ -58,11 +70,22 @@ class SeedPhrasesCompleter extends StatelessWidget { } } +/// A widget representing a single slot for selecting a seed phrase +/// within the [SeedPhrasesCompleter]. class _WordSlotCell extends StatelessWidget { + /// The currently selected seed phrase for this slot (can be null). final String? data; + + /// The slot number (1-based). final int slotNr; + + /// Whether the slot is currently being filled (active). final bool isActive; + + /// Whether the slot shows a delete icon when filled. final bool showDelete; + + /// A callback function triggered when the slot is tapped (if allowed). final VoidCallback? onTap; Set get states => { diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart index 785ad957499..9ff3a944eb6 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_picker.dart @@ -1,10 +1,22 @@ import 'package:catalyst_voices/widgets/common/columns_row.dart'; +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_completer.dart'; import 'package:flutter/material.dart'; +/// A widget that displays a grid of seed phrases with selection functionality. +/// +/// Typically used together with a [SeedPhrasesCompleter]. class SeedPhrasesPicker extends StatelessWidget { + /// The number of columns to use for displaying the seed phrases. + /// Defaults to 2. final int columnsCount; + + /// The list of seed phrases to be displayed. final List words; + + /// A set of currently selected seed phrases. Defaults to an empty set. final Set selectedWords; + + /// A callback function triggered when a non-selected seed phrase is tapped. final ValueChanged? onWordTap; const SeedPhrasesPicker({ @@ -43,9 +55,16 @@ class SeedPhrasesPicker extends StatelessWidget { } } +/// A widget representing a single seed phrase cell within the +/// [SeedPhrasesPicker]. class _WordCell extends StatelessWidget { + /// The seed phrase word to be displayed. final String data; + + /// Whether the seed phrase is currently selected. final bool isSelected; + + /// A callback function triggered when the cell is tapped (if not selected). final VoidCallback? onTap; Set get states => { diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart index 3d3ef3e9c4e..d38e9301508 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart @@ -4,10 +4,22 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +/// A widget that allows users to sequence a set of seed phrases. +/// +/// It provides a two-section interface: +/// - A completer section where users can fill slots with selected phrases. +/// - A picker section where users can select available phrases. +/// +/// The selected phrases are managed internally and updated through the +/// [onChanged] callback. class SeedPhrasesSequencer extends StatefulWidget { + /// The list of available seed phrases. final List words; + + /// A callback function triggered when the set of selected phrases changes. final ValueChanged> onChanged; + /// Creates a [SeedPhrasesSequencer] widget. const SeedPhrasesSequencer({ super.key, required this.words, diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart index 3e9a648596f..7b62506701c 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_viewer.dart @@ -3,8 +3,14 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +/// Displays a list of seed phrases in a grid-like layout with +/// customizable columns. class SeedPhrasesViewer extends StatelessWidget { + /// The number of columns to use for displaying the seed phrases. + /// Defaults to 2. final int columnsCount; + + /// The list of seed phrases to be displayed. final List words; const SeedPhrasesViewer({ @@ -32,10 +38,15 @@ class SeedPhrasesViewer extends StatelessWidget { } } +/// A widget representing a single seed phrase cell within the +/// [SeedPhrasesViewer]. class _WordCell extends StatelessWidget { - final int number; + /// The seed phrase word to be displayed. final String data; + /// The sequential number associated with the seed phrase. + final int number; + const _WordCell( this.data, { super.key, From 0f078252a9ed173e8d9bd1420632eabfb558acfd Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Tue, 20 Aug 2024 14:56:13 +0200 Subject: [PATCH 09/10] tests for ColumnsRow widget --- .../test/widgets/common/columns_row_test.dart | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 catalyst_voices/test/widgets/common/columns_row_test.dart diff --git a/catalyst_voices/test/widgets/common/columns_row_test.dart b/catalyst_voices/test/widgets/common/columns_row_test.dart new file mode 100644 index 00000000000..6454711974c --- /dev/null +++ b/catalyst_voices/test/widgets/common/columns_row_test.dart @@ -0,0 +1,132 @@ +import 'package:catalyst_voices/widgets/common/columns_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ColumnsRow', () { + testWidgets( + 'basic layout', + (tester) async { + // Given + final children = List.generate( + 6, + (index) => Container(key: Key('child_$index')), + ); + + final widget = MaterialApp( + home: ColumnsRow( + columnsCount: 3, + children: children, + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect(find.byKey(const Key('child_0')), findsOneWidget); + expect(find.byKey(const Key('child_3')), findsOneWidget); + expect(find.byKey(const Key('child_5')), findsOneWidget); + }, + ); + + testWidgets( + 'renders correctly with default spacing', + (tester) async { + // Given + final children = List.generate( + 6, + (index) => Text('Item $index'), + ); + + final widget = MaterialApp( + home: ColumnsRow( + columnsCount: 2, + children: children, + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + for (var i = 0; i < children.length; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + + expect(find.byType(Column), findsNWidgets(2)); + }, + ); + + testWidgets( + 'correctly arranges children in specified columns', + (tester) async { + // Given + const mainAxisSpacing = 12.0; + const crossAxisSpacing = 8.0; + final children = List.generate( + 8, + (index) => Text('Item $index'), + ); + + final widget = MaterialApp( + home: ColumnsRow( + columnsCount: 3, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + children: children, + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + for (var i = 0; i < children.length; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + + // Check the structure of the Column widgets + expect(find.byType(Column), findsNWidgets(3)); + + final columnWidgets = find.byType(Column).evaluate().toList(); + expect(columnWidgets.length, 3); + + final firstColumnChildren = columnWidgets[0].widget as Column; + final secondColumnChildren = columnWidgets[1].widget as Column; + final thirdColumnChildren = columnWidgets[2].widget as Column; + + expect((firstColumnChildren.children.first as Text).data, 'Item 0'); + expect( + (firstColumnChildren.children[1] as SizedBox).height, + crossAxisSpacing, + ); + expect((firstColumnChildren.children[2] as Text).data, 'Item 1'); + expect( + (firstColumnChildren.children[3] as SizedBox).height, + crossAxisSpacing, + ); + expect((firstColumnChildren.children[4] as Text).data, 'Item 2'); + + expect((secondColumnChildren.children.first as Text).data, 'Item 3'); + expect( + (secondColumnChildren.children[1] as SizedBox).height, + crossAxisSpacing, + ); + expect((secondColumnChildren.children[2] as Text).data, 'Item 4'); + expect( + (secondColumnChildren.children[3] as SizedBox).height, + crossAxisSpacing, + ); + expect((secondColumnChildren.children[4] as Text).data, 'Item 5'); + + expect((thirdColumnChildren.children.first as Text).data, 'Item 6'); + expect( + (thirdColumnChildren.children[1] as SizedBox).height, + crossAxisSpacing, + ); + expect((thirdColumnChildren.children[2] as Text).data, 'Item 7'); + }, + ); + }); +} From e7e165a4515df845f37966d408bd904ee653901f Mon Sep 17 00:00:00 2001 From: Damian Molinski Date: Wed, 21 Aug 2024 09:00:36 +0200 Subject: [PATCH 10/10] chore: seed_phrases_sequencer tests --- .../seed_phrase/seed_phrases_sequencer.dart | 3 +- .../seed_phrases_sequencer_test.dart | 97 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 catalyst_voices/test/widgets/seed_phrase/seed_phrases_sequencer_test.dart diff --git a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart index d38e9301508..651e3e3e48d 100644 --- a/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart +++ b/catalyst_voices/lib/widgets/seed_phrase/seed_phrases_sequencer.dart @@ -93,7 +93,8 @@ class _BorderDecorator extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colors.outlineBorderVariant!, + color: Theme.of(context).colors.outlineBorderVariant ?? + Theme.of(context).colorScheme.outlineVariant, width: 1.5, ), borderRadius: BorderRadius.circular(12), diff --git a/catalyst_voices/test/widgets/seed_phrase/seed_phrases_sequencer_test.dart b/catalyst_voices/test/widgets/seed_phrase/seed_phrases_sequencer_test.dart new file mode 100644 index 00000000000..cde953e2d27 --- /dev/null +++ b/catalyst_voices/test/widgets/seed_phrase/seed_phrases_sequencer_test.dart @@ -0,0 +1,97 @@ +import 'package:catalyst_voices/widgets/seed_phrase/seed_phrases_sequencer.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SeedPhrasesSequencer', () { + const words = ['real', 'mission', 'secure', 'renew', 'key', 'audit']; + + testWidgets( + 'clicking word in picker triggers on change callback', + (tester) async { + // Given + final selectedWords = {}; + final word = words[0]; + final expectedWords = {word}; + + final sequencer = SeedPhrasesSequencer( + words: words, + onChanged: (value) { + selectedWords + ..clear() + ..addAll(value); + }, + ); + final widget = MaterialApp( + theme: ThemeData( + extensions: const [ + VoicesColorScheme.optional(), + ], + ), + home: sequencer, + ); + final wordPickerKey = ValueKey('PickerSeedPhrase${word}CellKey'); + + // When + await tester.pumpWidget(widget); + await tester.tap(find.byKey(wordPickerKey)); + + // Then + expect(selectedWords, expectedWords); + }, + ); + + testWidgets( + 'clicking last word in completer removes word from list', + (tester) async { + // Given + final selectedWords = {}; + final expectedWords = {words[0]}; + + final sequencer = SeedPhrasesSequencer( + words: words, + onChanged: (value) { + selectedWords + ..clear() + ..addAll(value); + }, + ); + final widget = MaterialApp( + theme: ThemeData( + extensions: const [ + VoicesColorScheme.optional(), + ], + ), + home: sequencer, + ); + + final pickerKeys = >[ + ValueKey('PickerSeedPhrase${words[0]}CellKey'), + ValueKey('PickerSeedPhrase${words[1]}CellKey'), + ]; + final completerKeys = >[ + const ValueKey('CompleterSeedPhrase${1}CellKey'), + ]; + + // When + await tester.pumpWidget(widget); + + // Adds items to selectedWords + for (final key in pickerKeys) { + await tester.tap(find.byKey(key)); + } + + await tester.pumpAndSettle(); + + // Removes from selectedWords + for (final key in completerKeys) { + await tester.tap(find.byKey(key)); + } + + // Then + expect(selectedWords, expectedWords); + }, + ); + }); +}