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(cat-voices): TagSelection #1455

Merged
merged 17 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions catalyst_voices/apps/voices/lib/common/ext/string_ext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ extension StringExt on String {
return '';
}
}

String starred({
bool leading = true,
bool isEnabled = true,
}) {
if (!isEnabled) {
return this;
}

return leading ? withPrefix('*') : withSuffix('*');
}

String withPrefix(String value) => '$value$this';

String withSuffix(String value) => '$this$value';
}

extension UrlParser on String {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:catalyst_voices/common/ext/string_ext.dart';
import 'package:catalyst_voices/widgets/text_field/token_field.dart';
import 'package:catalyst_voices/widgets/text_field/voices_int_field.dart';
import 'package:catalyst_voices_models/catalyst_voices_models.dart';
Expand Down Expand Up @@ -67,16 +68,11 @@ class _DocumentTokenValueWidgetState extends State<DocumentTokenValueWidget> {

@override
Widget build(BuildContext context) {
var label = widget.label;
if (widget.isRequired) {
label = '*$label';
}

return TokenField(
controller: _controller,
focusNode: _focusNode,
onFieldSubmitted: _notifyChangeListener,
labelText: label,
labelText: widget.label.starred(isEnabled: widget.isRequired),
range: widget.range,
currency: widget.currency,
showHelper: widget.isEditMode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import 'package:catalyst_voices/common/ext/string_ext.dart';
import 'package:catalyst_voices/widgets/widgets.dart';
import 'package:catalyst_voices_brands/catalyst_voices_brands.dart';
import 'package:catalyst_voices_localization/catalyst_voices_localization.dart';
import 'package:catalyst_voices_models/catalyst_voices_models.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';

class SingleGroupedTagSelectorWidget extends StatefulWidget {
final DocumentNodeId id;
final GroupedTagsSelection selection;
final List<GroupedTags> groupedTags;
final bool isEditMode;
final ValueChanged<DocumentChange> onChanged;
final bool isRequired;

const SingleGroupedTagSelectorWidget({
super.key,
required this.id,
this.selection = const GroupedTagsSelection(),
required this.groupedTags,
required this.isEditMode,
required this.onChanged,
required this.isRequired,
});

@override
State<SingleGroupedTagSelectorWidget> createState() {
return _SingleGroupedTagSelectorWidgetState();
}
}

class _SingleGroupedTagSelectorWidgetState
extends State<SingleGroupedTagSelectorWidget> {
GroupedTagsSelection _selection = const GroupedTagsSelection();

@override
void initState() {
super.initState();

_selection = widget.selection;
}

@override
void didUpdateWidget(covariant SingleGroupedTagSelectorWidget oldWidget) {
super.didUpdateWidget(oldWidget);

if (widget.selection != oldWidget.selection) {
_selection = widget.selection;
}
}

@override
Widget build(BuildContext context) {
if (widget.isEditMode) {
return _TagSelector(
groupedTags: widget.groupedTags,
selection: _selection,
onGroupChanged: _handleGroupedTagsSelection,
onSelectionChanged: _handleTagSelection,
isRequired: widget.isRequired,
);
} else {
return _GroupedTagChip(_selection);
}
}

void _handleGroupedTagsSelection(GroupedTags? groupedTags) {
setState(() {
final groupChanged = _selection.group != groupedTags?.group;

_selection = _selection.copyWith(
group: Optional(groupedTags?.group),
tag: groupChanged ? const Optional<String>.empty() : null,
);
});
}

void _handleTagSelection(GroupedTagsSelection value) {
setState(() {
_selection = value;

final change = DocumentChange(nodeId: widget.id, value: value);
widget.onChanged(change);
});
}
}

class _TagSelector extends StatelessWidget {
final List<GroupedTags> groupedTags;
final GroupedTagsSelection selection;
final ValueChanged<GroupedTags?> onGroupChanged;
final ValueChanged<GroupedTagsSelection> onSelectionChanged;
final bool isRequired;

GroupedTags? get _selectedGroupedTags {
final selected = groupedTags.firstWhereOrNull(selection.selects);

return selected ?? groupedTags.firstOrNull;
}

const _TagSelector({
required this.groupedTags,
required this.selection,
required this.onGroupChanged,
required this.onSelectionChanged,
required this.isRequired,
});

@override
Widget build(BuildContext context) {
final selectedGroup = _selectedGroupedTags;

final tags = selectedGroup?.tags ?? const [];
final selectedTag = tags.contains(selection.tag) ? selection.tag : null;

return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_TagSelectorLabel(
context.l10n.singleGroupedTagSelectorTitle,
starred: isRequired,
),
const SizedBox(height: 4),
_TagGroupsDropdown(
groupedTags: groupedTags,
onChanged: onGroupChanged,
value: selectedGroup,
),
const SizedBox(height: 8.5),
_TagSelectorLabel(
context.l10n.singleGroupedTagSelectorRelevantTag,
starred: isRequired,
),
const SizedBox(height: 12),
_TagChipGroup(
tags: tags,
selectedTag: selectedTag,
onChanged: (tag) {
final group = selectedGroup!.group;

final selection = GroupedTagsSelection(group: group, tag: tag);

onSelectionChanged(selection);
},
),
],
);
}
}

class _TagSelectorLabel extends StatelessWidget {
final String data;
final bool starred;

const _TagSelectorLabel(
this.data, {
required this.starred,
});

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colors;
final textTheme = theme.textTheme;

return Text(
data.starred(isEnabled: starred),
style: textTheme.titleSmall?.copyWith(color: colors.textOnPrimaryLevel0),
);
}
}

class _TagGroupsDropdown extends StatelessWidget {
final List<GroupedTags> groupedTags;
final ValueChanged<GroupedTags?> onChanged;
final GroupedTags? value;

const _TagGroupsDropdown({
required this.groupedTags,
required this.onChanged,
required this.value,
});

@override
Widget build(BuildContext context) {
return VoicesDropdownFormField<GroupedTags>(
items: groupedTags
.map((e) => DropdownMenuItem(value: e, child: Text(e.group)))
.toList(),
value: value,
onChanged: onChanged,
);
}
}

class _TagChipGroup extends StatelessWidget {
final List<String> tags;
final String? selectedTag;
final ValueChanged<String?> onChanged;

const _TagChipGroup({
required this.tags,
this.selectedTag,
required this.onChanged,
});

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

return Wrap(
spacing: 10,
runSpacing: 10,
children: tags.map((tag) {
final isSelected = tag == selectedTag;
return _TagChip(
key: ObjectKey(tag),
name: tag,
isSelected: isSelected,
isEnabled: true,
onTap: () => isSelected ? onChanged(null) : onChanged(tag),
);
}).toList(),
);
}
}

class _GroupedTagChip extends StatelessWidget {
final GroupedTagsSelection data;

const _GroupedTagChip(
this.data,
);

@override
Widget build(BuildContext context) {
final isValid = data.isValid;

return _TagChip(
key: const ValueKey('SelectedGroupedTagChipKey'),
name: isValid ? '$data' : context.l10n.noTagSelected,
isSelected: isValid,
isEnabled: isValid,
);
}
}

class _TagChip extends StatelessWidget {
final String name;
final bool isSelected;
final bool isEnabled;
final VoidCallback? onTap;

Set<WidgetState> get states => {
if (isSelected) WidgetState.selected,
if (!isEnabled) WidgetState.disabled,
};

const _TagChip({
super.key,
required this.name,
this.isSelected = false,
this.isEnabled = true,
this.onTap,
});

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

final backgroundColor = WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.disabled)) {
return colors.onSurfaceNeutralOpaqueLv2;
}

if (states.contains(WidgetState.selected)) {
return colors.onPrimaryContainer;
}

return null;
});

final foregroundColor = WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.disabled)) {
return colors.textDisabled;
}

if (states.contains(WidgetState.selected)) {
return colors.textOnPrimaryWhite;
}

return null;
});

return VoicesChip.rectangular(
content: Text(
name,
style: TextStyle(color: foregroundColor.resolve(states)),
),
trailing: onTap != null && isSelected
? Icon(
Icons.clear,
color: foregroundColor.resolve(states),
)
: null,
backgroundColor: backgroundColor.resolve(states),
onTap: onTap,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart';
import 'package:catalyst_voices_brands/catalyst_voices_brands.dart';
import 'package:flutter/material.dart';

class VoicesDropdownFormField<T> extends StatelessWidget {
final List<DropdownMenuItem<T>>? items;
final T? value;
final ValueChanged<T?>? onChanged;

const VoicesDropdownFormField({
super.key,
required this.items,
this.value,
required this.onChanged,
});

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

return DropdownButtonFormField<T>(
items: items,
value: value,
onChanged: onChanged,
icon: VoicesAssets.icons.chevronDown.buildIcon(),
style: theme.textTheme.bodyLarge,
dropdownColor: theme.colors.onSurfaceNeutralOpaqueLv1,
);
}
}
Loading
Loading