Skip to content

Commit

Permalink
feat: VoicesCheckbox, VoicesCheckboxGroup, VoicesRadio (#663)
Browse files Browse the repository at this point in the history
* feat: Basic VoicesRadio and theming

* feat: VoicesRadio now accepts Label and Note widgets + update example page

* Themed VoicesCheckbox + example

* feat: Checkbox label + note. Update docs plus examples

* doc: LabelDecorator docs

* doc: Missing docs dots

* fix: add toggleable to project.dlc

* fix: remove unnecessary null unwrapping

* test: AffixDecorator tests

* LabelDecorator tests

* feat: CheckboxGroup initial draft

* feat: finish VoicesCheckboxGroup widget

* fix: static analyze warning + some VoicesCheckboxGroupElement tests

* test: VoicesCheckboxGroup and VoicesCheckbox tests

* fix: test name typo
  • Loading branch information
damian-molinski authored Aug 7, 2024
1 parent 178813f commit c420b00
Show file tree
Hide file tree
Showing 17 changed files with 1,331 additions and 1 deletion.
1 change: 1 addition & 0 deletions .config/dictionaries/project.dic
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,4 @@ xcworkspace
yoroi
unchunk
EUTXO
toggleable
55 changes: 55 additions & 0 deletions catalyst_voices/lib/widgets/common/label_decorator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:catalyst_voices_brands/catalyst_voices_brands.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';

class LabelDecorator extends StatelessWidget {
/// An optional widget to display the label text next to the child.
final Widget? label;

/// An optional widget to display the note text next to the child.
final Widget? note;

/// The main content of the decorator.
final Widget child;

const LabelDecorator({
super.key,
this.label,
this.note,
required this.child,
});

@override
Widget build(BuildContext context) {
final label = this.label;
final note = this.note;
final theme = Theme.of(context);

return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: child),
if (label != null)
DefaultTextStyle(
style: (theme.textTheme.bodyLarge ?? const TextStyle())
.copyWith(color: theme.colors.textPrimary),
child: label,
),
if (note != null)
DefaultTextStyle(
style: (theme.textTheme.bodySmall ?? const TextStyle())
.copyWith(color: theme.colors.textOnPrimary),
child: note,
),
].expandIndexed(
(index, element) {
return [
if (index == 1) const SizedBox(width: 4),
if (index == 2) const SizedBox(width: 8),
element,
];
},
).toList(),
);
}
}
57 changes: 57 additions & 0 deletions catalyst_voices/lib/widgets/toggles/voices_checkbox.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:catalyst_voices/widgets/common/label_decorator.dart';
import 'package:flutter/material.dart';

/// A checkbox widget with optional label, note, and error state.
///
/// This widget provides a visual representation of a boolean value and allows
/// users to toggle its state. It can display an optional label and note
/// for context, as well as indicate an error state through visual styling.
class VoicesCheckbox extends StatelessWidget {
/// The current value of the checkbox.
final bool value;

/// Callback function invoked when the checkbox value changes.
///
/// The function receives the new value as a parameter.
///
/// When null widget is disabled.
final ValueChanged<bool>? onChanged;

/// Indicates whether the checkbox is in an error state.
final bool isError;

/// An optional widget to display the label text.
final Widget? label;

/// An optional widget to display the note text.
final Widget? note;

const VoicesCheckbox({
super.key,
required this.value,
required this.onChanged,
this.isError = false,
this.label,
this.note,
});

@override
Widget build(BuildContext context) {
final onChanged = this.onChanged;

return GestureDetector(
onTap: onChanged != null ? () => onChanged(!value) : null,
behavior: HitTestBehavior.opaque,
child: LabelDecorator(
label: label,
note: note,
child: Checkbox(
value: value,
// forcing null unwrapping because we're not allowing null value
onChanged: onChanged != null ? (value) => onChanged(value!) : null,
isError: isError,
),
),
);
}
}
152 changes: 152 additions & 0 deletions catalyst_voices/lib/widgets/toggles/voices_checkbox_group.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'package:catalyst_voices/widgets/toggles/voices_checkbox.dart';
import 'package:flutter/material.dart';

/// Data describing a single checkbox element within a [VoicesCheckboxGroup].
///
/// This class holds information for a single checkbox element displayed
/// within a [VoicesCheckboxGroup].
///
/// Type Parameters:
///
/// * T: The type of the value associated with the checkbox element.
final class VoicesCheckboxGroupElement<T> {
/// The value associated with the checkbox element.
final T value;

/// A widget to display the text label for the checkbox.
final Widget? label;

/// An optional widget to display additional information below the label.
final Widget? note;

/// A flag indicating if the checkbox represents an error state.
final bool isError;

/// Default constructor for [VoicesCheckboxGroupElement].
///
/// Should have at least [label] or [note].
const VoicesCheckboxGroupElement({
required this.value,
this.label,
this.note,
this.isError = false,
}) : assert(
label != null || note != null,
'Should have at least label or note',
);
}

/// A widget that groups a list of checkboxes in a column with a name
/// and the ability to toggle all elements at once.
///
/// This widget displays a group of checkboxes with a shared name. It allows
/// users to select individual checkboxes and provides a master checkbox to
/// toggle all elements on/off simultaneously. You can also optionally specify
/// a callback function to be notified when the selection changes.
///
/// Type Parameters:
///
/// * T: The type of the value associated with each checkbox element.
class VoicesCheckboxGroup<T extends Object> extends StatelessWidget {
/// The name displayed for the checkbox group.
final Widget name;

/// A list of [VoicesCheckboxGroupElement] objects defining each checkbox.
final List<VoicesCheckboxGroupElement<T>> elements;

/// A set of currently selected values within the group.
final Set<T> selected;

/// An optional callback function to be called when the selection changes.
final ValueChanged<Set<T>>? onChanged;

/// How the checkboxes should be aligned horizontally within the group.
///
/// Defaults to [CrossAxisAlignment.start].
final CrossAxisAlignment crossAxisAlignment;

bool get _isGroupEnabled => onChanged != null;

bool get _isGroupSelected =>
elements.every((element) => selected.contains(element.value));

const VoicesCheckboxGroup({
super.key,
required this.name,
required this.elements,
required this.selected,
this.onChanged,
this.crossAxisAlignment = CrossAxisAlignment.start,
}) : assert(elements.length > 0, 'Elements have to be non empty');

@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment,
children: [
VoicesCheckbox(
value: _isGroupSelected,
label: name,
onChanged: _isGroupEnabled ? _toggleAll : null,
),
Padding(
padding: const EdgeInsetsDirectional.only(start: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment,
children: elements
.map(
(element) {
return VoicesCheckbox(
value: selected.contains(element.value),
label: element.label,
note: element.note,
onChanged: _isGroupEnabled
? (value) => _updateElement(element.value, value)
: null,
isError: element.isError,
);
},
)
.expand(
(element) => [
const SizedBox(height: 16),
element,
],
)
.toList(),
),
),
],
);
}

void _toggleAll(bool isChecked) {
assert(
onChanged != null,
'Toggled group status but change callback is null',
);

final updatedSelection =
isChecked ? elements.map((e) => e.value).toSet() : <T>{};

onChanged!(updatedSelection);
}

void _updateElement(T value, bool isChecked) {
assert(
onChanged != null,
'Toggled group status but change callback is null',
);

final updatedSelection = <T>{...selected};
if (isChecked) {
updatedSelection.add(value);
} else {
updatedSelection.remove(value);
}

onChanged!(updatedSelection);
}
}
73 changes: 73 additions & 0 deletions catalyst_voices/lib/widgets/toggles/voices_radio.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'package:catalyst_voices/widgets/common/label_decorator.dart';
import 'package:flutter/material.dart';

/// A Voices Radio widget that combines a Radio button with optional
/// label and note text elements.
///
/// Provides a convenient way to create radio buttons with associated
/// information in a visually appealing manner.
class VoicesRadio<T extends Object> extends StatelessWidget {
/// The value associated with this radio button.
final T value;

/// An optional widget to display the label text.
final Widget? label;

/// An optional widget to display the note text.
final Widget? note;

/// The current selected value in the radio group.
final T? groupValue;

/// Whether the radio button value can be changed by widget itself.
final bool toggleable;

/// A callback function that is called when the radio button value changes.
///
/// When null widget is disabled.
final ValueChanged<T?>? onChanged;

const VoicesRadio({
required this.value,
this.label,
this.note,
required this.groupValue,
this.toggleable = false,
required this.onChanged,
super.key,
});

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onChanged != null ? _changeGroupValue : null,
behavior: HitTestBehavior.opaque,
child: LabelDecorator(
label: label,
note: note,
child: Radio(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
),
),
);
}

void _changeGroupValue() {
final onChanged = this.onChanged;
assert(
onChanged != null,
'Make sure onChange is not null when using _changeGroupValue',
);

if (groupValue == value && toggleable) {
onChanged!(null);
}

if (groupValue != value) {
onChanged!(value);
}
}
}
3 changes: 3 additions & 0 deletions catalyst_voices/lib/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ export 'buttons/voices_text_button.dart';
export 'text_field/voices_email_text_field.dart';
export 'text_field/voices_password_text_field.dart';
export 'text_field/voices_text_field.dart';
export 'toggles/voices_checkbox.dart';
export 'toggles/voices_checkbox_group.dart';
export 'toggles/voices_radio.dart';
Loading

0 comments on commit c420b00

Please sign in to comment.