Skip to content

Commit

Permalink
feat: SeedPhrasesSequencer and SeedPhrasesViewer (#702)
Browse files Browse the repository at this point in the history
* 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
damian-molinski authored Aug 21, 2024
1 parent 7c2dec6 commit 8fe41de
Show file tree
Hide file tree
Showing 10 changed files with 908 additions and 0 deletions.
88 changes: 88 additions & 0 deletions catalyst_voices/lib/widgets/common/columns_row.dart
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 catalyst_voices/lib/widgets/seed_phrase/seed_phrases_completer.dart
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;
}
}
Loading

0 comments on commit 8fe41de

Please sign in to comment.