Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SeedPhrasesSequencer and SeedPhrasesViewer #702

Merged
merged 11 commits into from
Aug 21, 2024
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 {
dtscalac marked this conversation as resolved.
Show resolved Hide resolved
/// 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
Loading