Skip to content

Commit 75e36f1

Browse files
authored
feat: [DX-1203] Add OptimusSelectionCard component (#656)
1 parent 800a3a3 commit 75e36f1

File tree

4 files changed

+389
-0
lines changed

4 files changed

+389
-0
lines changed

optimus/lib/optimus.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export 'src/search/search_field.dart';
8080
export 'src/segmented_control/segmented_control.dart';
8181
export 'src/select.dart';
8282
export 'src/select_input.dart';
83+
export 'src/selection_card.dart';
8384
export 'src/slidable/slidable.dart';
8485
export 'src/slidable/slide_action.dart';
8586
export 'src/stack.dart';

optimus/lib/src/selection_card.dart

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import 'package:dfunc/dfunc.dart';
2+
import 'package:flutter/widgets.dart';
3+
import 'package:optimus/optimus.dart';
4+
import 'package:optimus/src/checkbox/checkbox_tick.dart';
5+
import 'package:optimus/src/common/gesture_wrapper.dart';
6+
import 'package:optimus/src/common/state_property.dart';
7+
import 'package:optimus/src/radio/radio_circle.dart';
8+
9+
enum OptimusSelectionCardVariant { vertical, horizontal }
10+
11+
enum OptimusSelectionCardBorderRadius { small, medium }
12+
13+
enum OptimusSelectionCardSelectionVariant { radio, checkbox }
14+
15+
/// A card that represents a choice. Depending on the [selectionVariant], it can
16+
/// be a radio button or a checkbox, hence single or multiple selection.
17+
/// The card consists of a title, an optional description, and an optional
18+
/// trailing widget.
19+
class OptimusSelectionCard extends StatefulWidget {
20+
const OptimusSelectionCard({
21+
super.key,
22+
required this.title,
23+
this.description,
24+
this.trailing,
25+
this.variant = OptimusSelectionCardVariant.horizontal,
26+
this.borderRadius = OptimusSelectionCardBorderRadius.medium,
27+
this.selectionVariant = OptimusSelectionCardSelectionVariant.radio,
28+
this.isSelected = false,
29+
this.isSelectorVisible = true,
30+
this.isEnabled = true,
31+
this.onPressed,
32+
});
33+
34+
/// The title of the card.
35+
final Widget title;
36+
37+
/// The description of the card.
38+
final Widget? description;
39+
40+
/// The trailing widget of the card.
41+
final Widget? trailing;
42+
43+
/// The variant of the card. Default is [OptimusSelectionCardVariant.horizontal].
44+
final OptimusSelectionCardVariant variant;
45+
46+
/// The border radius of the card. Default is [OptimusSelectionCardBorderRadius.medium].
47+
final OptimusSelectionCardBorderRadius borderRadius;
48+
49+
/// The selection variant of the card. Default is [OptimusSelectionCardSelectionVariant.radio].
50+
final OptimusSelectionCardSelectionVariant selectionVariant;
51+
52+
/// Whether the card is selected. Default is false.
53+
final bool isSelected;
54+
55+
/// Whether the selector is shown. Default is true.
56+
final bool isSelectorVisible;
57+
58+
/// Whether the card is enabled. Default is true.
59+
final bool isEnabled;
60+
61+
/// The callback that is called when the card is pressed.
62+
final VoidCallback? onPressed;
63+
64+
@override
65+
State<OptimusSelectionCard> createState() => _OptimusSelectionCardState();
66+
}
67+
68+
class _OptimusSelectionCardState extends State<OptimusSelectionCard>
69+
with ThemeGetter {
70+
final _controller = WidgetStatesController();
71+
72+
@override
73+
void dispose() {
74+
_controller.dispose();
75+
super.dispose();
76+
}
77+
78+
InteractiveStateColor get _backgroundColor => InteractiveStateColor(
79+
defaultColor: widget.isSelected
80+
? tokens.backgroundInteractiveSecondaryDefault
81+
: tokens.backgroundStaticFlat,
82+
disabled: tokens.backgroundStaticFlat,
83+
pressed: widget.isSelected
84+
? tokens.backgroundInteractiveSecondaryActive
85+
: tokens.backgroundStaticFlat,
86+
hovered: widget.isSelected
87+
? tokens.backgroundInteractiveSecondaryHover
88+
: tokens.backgroundStaticFlat,
89+
);
90+
91+
InteractiveStateColor get _borderColor => InteractiveStateColor(
92+
defaultColor: widget.isSelected
93+
? tokens.borderInteractivePrimaryDefault
94+
: tokens.borderInteractiveSecondaryDefault,
95+
disabled: tokens.borderDisabled,
96+
pressed: widget.isSelected
97+
? tokens.borderInteractivePrimaryActive
98+
: tokens.borderInteractiveSecondaryActive,
99+
hovered: widget.isSelected
100+
? tokens.borderInteractivePrimaryHover
101+
: tokens.borderInteractiveSecondaryHover,
102+
);
103+
104+
InteractiveStateColor get _titleColor => InteractiveStateColor(
105+
defaultColor: tokens.textStaticPrimary,
106+
disabled: tokens.textDisabled,
107+
pressed: tokens.textStaticPrimary,
108+
hovered: tokens.textStaticPrimary,
109+
);
110+
111+
InteractiveStateColor get _descriptionColor => InteractiveStateColor(
112+
defaultColor: tokens.textStaticTertiary,
113+
disabled: tokens.textDisabled,
114+
pressed: tokens.textStaticTertiary,
115+
hovered: tokens.textStaticTertiary,
116+
);
117+
118+
@override
119+
void didUpdateWidget(covariant OptimusSelectionCard oldWidget) {
120+
super.didUpdateWidget(oldWidget);
121+
if (widget.isEnabled != oldWidget.isEnabled) {
122+
_controller.update(WidgetState.disabled, !widget.isEnabled);
123+
}
124+
}
125+
126+
@override
127+
Widget build(BuildContext context) => IgnorePointer(
128+
ignoring: !widget.isEnabled,
129+
child: ListenableBuilder(
130+
listenable: _controller,
131+
builder: (context, _) {
132+
final backgroundColor = _backgroundColor.resolve(_controller.value);
133+
final borderColor = _borderColor.resolve(_controller.value);
134+
final titleColor = _titleColor.resolve(_controller.value);
135+
final descriptionColor =
136+
_descriptionColor.resolve(_controller.value);
137+
138+
final selector = widget.isSelectorVisible
139+
? switch (widget.selectionVariant) {
140+
OptimusSelectionCardSelectionVariant.radio => RadioCircle(
141+
controller: _controller,
142+
isSelected: widget.isSelected,
143+
),
144+
OptimusSelectionCardSelectionVariant.checkbox =>
145+
CheckboxTick(
146+
isEnabled: widget.isEnabled,
147+
isChecked: widget.isSelected,
148+
// ignore: prefer-boolean-prefixes, DCM bug
149+
onChanged: (_) {},
150+
onTap: () {},
151+
)
152+
}
153+
: null;
154+
155+
final title = DefaultTextStyle.merge(
156+
child: widget.title,
157+
style: tokens.bodyLargeStrong.copyWith(color: titleColor),
158+
);
159+
160+
final Widget? description = widget.description?.let(
161+
(it) => DefaultTextStyle.merge(
162+
child: it,
163+
style: tokens.bodyMedium.copyWith(color: descriptionColor),
164+
),
165+
);
166+
167+
final Widget? trailing = widget.trailing?.let(
168+
(it) => IconTheme.merge(
169+
child: it,
170+
data: IconThemeData(color: titleColor),
171+
),
172+
);
173+
174+
return GestureWrapper(
175+
onHoverChanged: (isHovered) =>
176+
_controller.update(WidgetState.hovered, isHovered),
177+
onPressedChanged: (isPressed) =>
178+
_controller.update(WidgetState.pressed, isPressed),
179+
onTap: widget.onPressed,
180+
child: DecoratedBox(
181+
decoration: BoxDecoration(
182+
color: backgroundColor,
183+
borderRadius: BorderRadius.all(
184+
widget.borderRadius.getBorderRadius(tokens),
185+
),
186+
border: Border.all(
187+
color: borderColor,
188+
width: tokens.borderWidth150,
189+
),
190+
),
191+
child: switch (widget.variant) {
192+
OptimusSelectionCardVariant.horizontal => _HorizontalCard(
193+
title: title,
194+
description: description,
195+
trailing: trailing,
196+
isSelected: widget.isSelected,
197+
selector: selector,
198+
),
199+
OptimusSelectionCardVariant.vertical => _VerticalCard(
200+
title: title,
201+
description: description,
202+
trailing: trailing,
203+
isSelected: widget.isSelected,
204+
selector: selector,
205+
)
206+
},
207+
),
208+
);
209+
},
210+
),
211+
);
212+
}
213+
214+
class _HorizontalCard extends StatelessWidget {
215+
const _HorizontalCard({
216+
required this.title,
217+
this.description,
218+
this.trailing,
219+
required this.isSelected,
220+
this.selector,
221+
});
222+
223+
final Widget title;
224+
final Widget? description;
225+
final Widget? trailing;
226+
final Widget? selector;
227+
final bool isSelected;
228+
229+
@override
230+
Widget build(BuildContext context) {
231+
final tokens = context.tokens;
232+
233+
return Padding(
234+
padding: EdgeInsets.all(tokens.spacing200),
235+
child: Row(
236+
mainAxisAlignment: MainAxisAlignment.start,
237+
mainAxisSize: MainAxisSize.min,
238+
children: [
239+
if (selector case final selector?) selector,
240+
Expanded(
241+
child: Padding(
242+
padding: EdgeInsets.symmetric(horizontal: tokens.spacing200),
243+
child: Column(
244+
mainAxisSize: MainAxisSize.min,
245+
crossAxisAlignment: CrossAxisAlignment.start,
246+
children: [
247+
Flexible(child: title),
248+
SizedBox(height: tokens.spacing25),
249+
if (description case final description?)
250+
Flexible(child: description),
251+
],
252+
),
253+
),
254+
),
255+
if (trailing case final trailing?) trailing,
256+
],
257+
),
258+
);
259+
}
260+
}
261+
262+
class _VerticalCard extends StatelessWidget {
263+
const _VerticalCard({
264+
required this.title,
265+
this.description,
266+
this.trailing,
267+
required this.isSelected,
268+
this.selector,
269+
});
270+
271+
final Widget title;
272+
final Widget? description;
273+
final Widget? trailing;
274+
final bool isSelected;
275+
final Widget? selector;
276+
277+
@override
278+
Widget build(BuildContext context) => Stack(
279+
alignment: Alignment.center,
280+
children: [
281+
if (selector case final selector?)
282+
Positioned(
283+
right: context.tokens.spacing100,
284+
top: context.tokens.spacing100,
285+
child: selector,
286+
),
287+
Container(
288+
padding: EdgeInsets.symmetric(
289+
horizontal: context.tokens.spacing200,
290+
vertical: context.tokens.spacing400,
291+
),
292+
child: Column(
293+
mainAxisSize: MainAxisSize.min,
294+
children: [
295+
if (trailing case final trailing?) trailing,
296+
SizedBox(
297+
height: context.tokens.spacing200,
298+
width: context.tokens.sizing1300,
299+
),
300+
Flexible(child: title),
301+
SizedBox(height: context.tokens.spacing50),
302+
if (description case final description?)
303+
Flexible(child: description),
304+
],
305+
),
306+
),
307+
],
308+
);
309+
}
310+
311+
extension on OptimusSelectionCardBorderRadius {
312+
Radius getBorderRadius(OptimusTokens tokens) {
313+
switch (this) {
314+
case OptimusSelectionCardBorderRadius.small:
315+
return tokens.borderRadius100;
316+
case OptimusSelectionCardBorderRadius.medium:
317+
return tokens.borderRadius200;
318+
}
319+
}
320+
}

storybook/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import 'package:storybook/stories/radio.dart';
4545
import 'package:storybook/stories/search_field.dart';
4646
import 'package:storybook/stories/segmented_control.dart';
4747
import 'package:storybook/stories/select_input.dart';
48+
import 'package:storybook/stories/selection_card.dart';
4849
import 'package:storybook/stories/slidable.dart';
4950
import 'package:storybook/stories/spacing.dart';
5051
import 'package:storybook/stories/stack.dart';
@@ -171,6 +172,7 @@ class _MyAppState extends State<MyApp> {
171172
passwordStory,
172173
toggleButtonStory,
173174
textAreaStory,
175+
selectionCardStory,
174176
],
175177
),
176178
},

0 commit comments

Comments
 (0)