-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: SeedPhrasesSequencer and SeedPhrasesViewer (#702)
* feat: First iteration of SeedPhrasesViewer * refactor: Extract ColumnsRow widget * fix: word overflow, update exemaple * feat: SeedPhrases Picker and Completer * feat: SeedPhrasesEditor * refactor: Rename SeedPhrasesEditor to SeedPhrasesSequencer * fix: clean up example seed words * chore: widgets docs * tests for ColumnsRow widget * chore: seed_phrases_sequencer tests
- Loading branch information
1 parent
7c2dec6
commit 8fe41de
Showing
10 changed files
with
908 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
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<Widget> 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) { | ||
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(), | ||
); | ||
} | ||
} | ||
|
||
/// A helper widget that arranges children vertically with optional spacing. | ||
class _Column extends StatelessWidget { | ||
final double spacing; | ||
final List<Widget> 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(), | ||
); | ||
} | ||
} |
203 changes: 203 additions & 0 deletions
203
catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
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<String> words; | ||
|
||
/// A callback function triggered when a non-filled slot or a filled but only | ||
/// for previous selection. | ||
final ValueChanged<String>? onWordTap; | ||
|
||
/// Creates a [SeedPhrasesCompleter] widget. | ||
const SeedPhrasesCompleter({ | ||
super.key, | ||
this.columnsCount = 2, | ||
required this.slotsCount, | ||
this.words = const <String>{}, | ||
this.onWordTap, | ||
}); | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final slots = List.generate(slotsCount, words.elementAtOrNull); | ||
final onWordTap = this.onWordTap; | ||
|
||
// Identify the currently active slot (being filled) and the | ||
// previous slot (deletable). | ||
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 || onWordTap == null | ||
? null | ||
: () => onWordTap(element), | ||
); | ||
}).toList(), | ||
), | ||
); | ||
} | ||
} | ||
|
||
/// 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<WidgetState> 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<Color?> { | ||
final ThemeData theme; | ||
|
||
_CellBackgroundColor(this.theme); | ||
|
||
@override | ||
Color? resolve(Set<WidgetState> 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<Color?> { | ||
final ThemeData theme; | ||
|
||
_CellForegroundColor(this.theme); | ||
|
||
@override | ||
Color? resolve(Set<WidgetState> 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<BoxBorder?> { | ||
final ThemeData theme; | ||
|
||
_CellBorder(this.theme); | ||
|
||
@override | ||
BoxBorder? resolve(Set<WidgetState> states) { | ||
if (states.contains(WidgetState.focused)) { | ||
return Border.all( | ||
color: theme.colorScheme.primary, | ||
); | ||
} | ||
|
||
return null; | ||
} | ||
} |
Oops, something went wrong.