diff --git a/README.md b/README.md index 703ca8e7..1aa10b68 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ With these configurations, Zeta makes it easy to achieve consistent theming thro ## Viewing the components -To view examples of all the components in the library, you can run the example app in this repo or go to [Zeta](https://zeta-ds.web.app/) +To view examples of all the components in the library, you can pull this repo and run either the example app or widgetbook instance. + +You can also view the latest release at [Zeta](https://zeta-ds.web.app/) or the latest commits to main [here](https://zeta-flutter-main.web.app/). ## Licensing diff --git a/example/lib/home.dart b/example/lib/home.dart index 329e411f..8c6465f4 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -15,6 +15,7 @@ import 'package:zeta_example/pages/components/dropdown_example.dart'; import 'package:zeta_example/pages/components/list_item_example.dart'; import 'package:zeta_example/pages/components/navigation_bar_example.dart'; import 'package:zeta_example/pages/components/radio_example.dart'; +import 'package:zeta_example/pages/components/stepper_example.dart'; import 'package:zeta_example/pages/components/switch_example.dart'; import 'package:zeta_example/pages/components/snackbar_example.dart'; import 'package:zeta_example/pages/components/tabs_example.dart'; @@ -51,6 +52,7 @@ final List components = [ Component(DropdownExample.name, (context) => const DropdownExample()), Component(ProgressExample.name, (context) => const ProgressExample()), Component(SnackBarExample.name, (context) => const SnackBarExample()), + Component(StepperExample.name, (context) => const StepperExample()), Component(TabsExample.name, (context) => const TabsExample()), Component(DialPadExample.name, (context) => const DialPadExample()), Component(RadioButtonExample.name, (context) => const RadioButtonExample()), diff --git a/example/lib/pages/components/stepper_example.dart b/example/lib/pages/components/stepper_example.dart new file mode 100644 index 00000000..594b43da --- /dev/null +++ b/example/lib/pages/components/stepper_example.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class StepperExample extends StatefulWidget { + const StepperExample({super.key}); + + static const String name = 'Stepper'; + + @override + State createState() => _StepperExampleState(); +} + +class _StepperExampleState extends State { + int _roundedHorizontalStep = 0; + int _sharpHorizontalStep = 0; + int _verticalStep = 0; + + ZetaStepType _getForStepIndex({ + required int currentStep, + required int stepIndex, + }) { + if (currentStep == stepIndex) return ZetaStepType.enabled; + if (currentStep > stepIndex) return ZetaStepType.complete; + + return ZetaStepType.disabled; + } + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: StepperExample.name, + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox( + height: 150, + child: ZetaStepper( + currentStep: _roundedHorizontalStep, + onStepTapped: (index) => setState(() => _roundedHorizontalStep = index), + steps: [ + ZetaStep( + type: _getForStepIndex( + currentStep: _roundedHorizontalStep, + stepIndex: 0, + ), + title: Text("Title"), + content: Text("Content"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _roundedHorizontalStep, + stepIndex: 1, + ), + title: Text("Title 2"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _roundedHorizontalStep, + stepIndex: 2, + ), + title: Text("Title 3"), + content: Text("Content 3"), + ), + ], + ), + ), + SizedBox( + height: 150, + child: ZetaStepper( + rounded: false, + currentStep: _sharpHorizontalStep, + onStepTapped: (index) => setState(() => _sharpHorizontalStep = index), + steps: [ + ZetaStep( + type: _getForStepIndex( + currentStep: _sharpHorizontalStep, + stepIndex: 0, + ), + title: Text("Title"), + content: Text("Content"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _sharpHorizontalStep, + stepIndex: 1, + ), + title: Text("Title 2"), + content: Text("Content 2"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _sharpHorizontalStep, + stepIndex: 2, + ), + title: Text("Title 3"), + content: Text("Content 3"), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.l), + child: ZetaStepper( + type: ZetaStepperType.vertical, + currentStep: _verticalStep, + onStepTapped: (index) => setState(() => _verticalStep = index), + steps: [ + ZetaStep( + type: _getForStepIndex( + currentStep: _verticalStep, + stepIndex: 0, + ), + title: Text("Title"), + subtitle: Text("Step Number"), + content: Text("Content"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _verticalStep, + stepIndex: 1, + ), + title: Text("Title 2"), + subtitle: Text("Step Number"), + content: Text("Content 2"), + ), + ZetaStep( + type: _getForStepIndex( + currentStep: _verticalStep, + stepIndex: 2, + ), + title: Text("Title 3"), + subtitle: Text("Step Number"), + content: Text("Content 3"), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index e75ae793..0a348249 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -21,6 +21,7 @@ import 'pages/components/navigation_bar_widgetbook.dart'; import 'pages/components/password_input_widgetbook.dart'; import 'pages/components/progress_widgetbook.dart'; import 'pages/components/radio_widgetbook.dart'; +import 'pages/components/stepper_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; import 'pages/components/snack_bar_widgetbook.dart'; import 'pages/components/tabs_widgetbook.dart'; @@ -100,6 +101,10 @@ class HotReload extends StatelessWidget { builder: (context) => snackBarUseCase(context), ), WidgetbookUseCase(name: 'Date Input', builder: (context) => dateInputUseCase(context)), + WidgetbookUseCase( + name: 'Stepper', + builder: (context) => stepperUseCase(context), + ), WidgetbookUseCase(name: 'Tabs', builder: (context) => tabsUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), diff --git a/example/widgetbook/pages/components/stepper_widgetbook.dart b/example/widgetbook/pages/components/stepper_widgetbook.dart new file mode 100644 index 00000000..4c06303f --- /dev/null +++ b/example/widgetbook/pages/components/stepper_widgetbook.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget stepperUseCase(BuildContext context) { + int currentStep = 0; + + ZetaStepType getForStepIndex(int stepIndex) { + if (currentStep == stepIndex) return ZetaStepType.enabled; + if (currentStep > stepIndex) return ZetaStepType.complete; + + return ZetaStepType.disabled; + } + + final type = context.knobs.list( + label: "Type", + options: [ + ZetaStepperType.horizontal, + ZetaStepperType.vertical, + ], + initialOption: ZetaStepperType.horizontal, + labelBuilder: (type) => type.name, + ); + + final rounded = context.knobs.boolean(label: 'Rounded', initialValue: true); + + final enabledContent = context.knobs.boolean(label: 'Enabled Content', initialValue: true); + + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + return Container( + height: type == ZetaStepperType.horizontal ? 300 : null, + padding: EdgeInsets.all( + type == ZetaStepperType.horizontal ? 0.0 : ZetaSpacing.l, + ), + child: ZetaStepper( + currentStep: currentStep, + onStepTapped: (index) => setState(() => currentStep = index), + rounded: type == ZetaStepperType.horizontal ? rounded : true, + type: type, + steps: [ + ZetaStep( + type: getForStepIndex(0), + title: Text("Title"), + content: enabledContent ? Text("Content") : null, + ), + ZetaStep( + type: getForStepIndex(1), + title: Text("Title 2"), + content: enabledContent ? Text("Content 2") : null, + ), + ZetaStep( + type: getForStepIndex(2), + title: Text("Title 3"), + content: enabledContent ? Text("Content 3") : null, + ), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/stepper/stepper.dart b/lib/src/components/stepper/stepper.dart new file mode 100644 index 00000000..25534fc9 --- /dev/null +++ b/lib/src/components/stepper/stepper.dart @@ -0,0 +1,414 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../zeta_flutter.dart'; + +/// Zeta stepper widget that displays progress through a sequence of +/// steps. Steppers are particularly useful in the case of forms where one step +/// requires the completion of another one, or where multiple steps need to be +/// completed in order to submit the whole form. +class ZetaStepper extends StatefulWidget { + /// Creates a stepper from a list of steps. + /// + /// This widget is not meant to be rebuilt with a different list of steps + /// unless a key is provided in order to distinguish the old stepper from the + /// new one. + const ZetaStepper({ + required this.steps, + required this.currentStep, + this.type = ZetaStepperType.horizontal, + this.onStepTapped, + this.rounded = true, + super.key, + }); + + /// The index into [steps] of the current step whose content is displayed. + final int currentStep; + + /// The callback called when a step is tapped, with its index passed as + /// an argument. + final ValueChanged? onStepTapped; + + /// Whether the icons of the horizontal stepper to be rounded or square. + final bool rounded; + + /// The steps of the stepper whose titles, subtitles, icons always get shown. + /// + /// The length of [steps] must not change. + final List steps; + + /// The type of stepper that determines the layout. In the case of + /// [ZetaStepperType.horizontal], the content of the current step is displayed + /// underneath as opposed to the [ZetaStepperType.vertical] case where it is + /// displayed in-between. + final ZetaStepperType type; + + @override + State createState() => _ZetaStepperState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('steps', steps)) + ..properties.add(IntProperty('currentStep', currentStep)) + ..add(EnumProperty('type', type)) + ..add( + ObjectFlagProperty?>.has( + 'onStepTapped', + onStepTapped, + ), + ) + ..properties.add(DiagnosticsProperty('rounded', rounded)); + } +} + +class _ZetaStepperState extends State with TickerProviderStateMixin { + late List _keys; + + @override + void initState() { + super.initState(); + _keys = List.generate( + widget.steps.length, + (_) => GlobalKey(), + ); + } + + ZetaColors get _colors => Zeta.of(context).colors; + + bool _isFirst(int index) { + return index == 0; + } + + bool _isLast(int index) { + return widget.steps.length - 1 == index; + } + + bool _isCurrent(int index) { + return widget.currentStep == index; + } + + Widget _buildHorizotalIcon(int index) { + return SizedBox( + width: ZetaSpacing.l, + height: ZetaSpacing.l, + child: AnimatedContainer( + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + decoration: BoxDecoration( + color: _getColorForType(widget.steps[index].type), + shape: widget.rounded ? BoxShape.circle : BoxShape.rectangle, + ), + child: Center( + child: switch (widget.steps[index].type) { + ZetaStepType.complete => Icon( + widget.rounded ? ZetaIcons.check_mark_round : ZetaIcons.check_mark_sharp, + color: _colors.textInverse, + ), + ZetaStepType.enabled || ZetaStepType.disabled => Text( + (index + 1).toString(), + style: ZetaTextStyles.labelLarge.copyWith( + color: _colors.textInverse, + ), + ), + }, + ), + ), + ); + } + + Widget _getVerticalIcon(int index) { + return SizedBox( + width: ZetaSpacing.x12, + height: ZetaSpacing.x12, + child: AnimatedContainer( + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + decoration: BoxDecoration( + color: _getColorForType(widget.steps[index].type), + shape: widget.rounded ? BoxShape.circle : BoxShape.rectangle, + ), + child: Center( + child: switch (widget.steps[index].type) { + ZetaStepType.complete => Icon( + widget.rounded ? ZetaIcons.check_mark_round : ZetaIcons.check_mark_sharp, + color: _colors.textInverse, + ), + ZetaStepType.enabled || ZetaStepType.disabled => Text( + (index + 1).toString(), + style: ZetaTextStyles.titleLarge.copyWith( + color: _colors.textInverse, + ), + ), + }, + ), + ), + ); + } + + Widget _getHeaderText(int index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedDefaultTextStyle( + style: switch (widget.steps[index].type) { + ZetaStepType.enabled || ZetaStepType.complete => ZetaTextStyles.bodySmall.copyWith( + color: _colors.textDefault, + ), + ZetaStepType.disabled => ZetaTextStyles.bodySmall.copyWith( + color: _colors.textDisabled, + ), + }, + maxLines: 1, + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: widget.steps[index].title, + ), + ], + ); + } + + Widget _getVerticalHeader(int index) { + final subtitle = widget.steps[index].subtitle; + + return Container( + margin: EdgeInsets.only(top: _isFirst(index) ? 0.0 : ZetaSpacing.m), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + _getVerticalIcon(index), + Container( + margin: const EdgeInsets.only(top: ZetaSpacing.x1), + width: ZetaSpacing.x1, + height: ZetaSpacing.x12, + decoration: BoxDecoration( + borderRadius: ZetaRadius.full, + color: switch (widget.steps[index].type) { + ZetaStepType.complete => _colors.green.shade50, + ZetaStepType.disabled => _colors.borderSubtle, + ZetaStepType.enabled => _colors.blue.shade50, + }, + ), + ), + ], + ), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: ZetaSpacing.m), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (subtitle != null) + AnimatedDefaultTextStyle( + style: ZetaTextStyles.bodyMedium.copyWith( + color: _getColorForType(widget.steps[index].type), + ), + maxLines: 1, + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: subtitle, + ), + AnimatedDefaultTextStyle( + style: switch (widget.steps[index].type) { + ZetaStepType.enabled || ZetaStepType.complete => ZetaTextStyles.titleLarge.copyWith( + color: _colors.textDefault, + ), + ZetaStepType.disabled => ZetaTextStyles.titleLarge.copyWith( + color: _colors.textDisabled, + ), + }, + maxLines: 1, + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: widget.steps[index].title, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _getVerticalBody(int index) { + return Stack( + children: [ + AnimatedCrossFade( + firstChild: Container(height: 0), + secondChild: widget.steps[index].content ?? const SizedBox(), + firstCurve: const Interval(0, 0.6, curve: Curves.fastOutSlowIn), + secondCurve: const Interval(0.4, 1, curve: Curves.fastOutSlowIn), + sizeCurve: Curves.fastOutSlowIn, + crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: kThemeAnimationDuration, + ), + ], + ); + } + + Color _getColorForType(ZetaStepType type) { + return switch (type) { + ZetaStepType.complete => _colors.positive, + ZetaStepType.disabled => _colors.cool.shade50, + ZetaStepType.enabled => _colors.primary, + }; + } + + @override + Widget build(BuildContext context) { + return switch (widget.type) { + ZetaStepperType.vertical => Column( + children: [ + for (int index = 0; index < widget.steps.length; index += 1) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + key: _keys[index], + children: [ + InkResponse( + containedInkWell: true, + borderRadius: ZetaRadius.minimal, + onTap: widget.onStepTapped != null ? () => widget.onStepTapped?.call(index) : null, + canRequestFocus: widget.steps[index].type != ZetaStepType.disabled, + child: _getVerticalHeader(index), + ), + _getVerticalBody(index), + ], + ), + ], + ), + ZetaStepperType.horizontal => Builder( + builder: (context) { + final children = [ + for (int index = 0; index < widget.steps.length; index += 1) ...[ + InkResponse( + onTap: widget.onStepTapped != null ? () => widget.onStepTapped?.call(index) : null, + canRequestFocus: widget.steps[index].type != ZetaStepType.disabled, + child: Column( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: ZetaSpacing.s, + ), + child: _buildHorizotalIcon(index), + ), + ), + _getHeaderText(index), + ], + ), + ), + if (!_isLast(index)) + Expanded( + child: Container( + key: Key('line$index'), + margin: const EdgeInsets.only( + top: ZetaSpacing.x7, + right: ZetaSpacing.b, + left: ZetaSpacing.b, + ), + height: ZetaSpacing.x0_5, + decoration: BoxDecoration( + borderRadius: ZetaRadius.full, + color: switch (widget.steps[index].type) { + ZetaStepType.complete => _colors.green.shade50, + ZetaStepType.disabled => _colors.borderSubtle, + ZetaStepType.enabled => _colors.blue.shade50, + }, + ), + ), + ), + ], + ]; + + final List stepPanels = []; + for (int i = 0; i < widget.steps.length; i += 1) { + stepPanels.add( + Visibility( + maintainState: true, + visible: i == widget.currentStep, + child: widget.steps[i].content ?? const SizedBox(), + ), + ); + } + + return Column( + children: [ + Material( + color: Colors.transparent, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: ZetaSpacing.m), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ), + Expanded( + child: AnimatedSize( + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: stepPanels, + ), + ), + ), + ], + ); + }, + ), + }; + } +} + +/// Zeta step used in [ZetaStepper]. The step can have a title and subtitle, +/// an icon within its circle, some content and a state that governs its +/// styling. +class ZetaStep { + /// Creates a step for a [ZetaStepper]. + const ZetaStep({ + required this.title, + this.content, + this.subtitle, + this.type = ZetaStepType.disabled, + }); + + /// The content of the step that appears below the [title] and [subtitle]. + final Widget? content; + + /// The subtitle of the step that appears above the title. + final Widget? subtitle; + + /// The title of the step that typically describes it. + final Widget title; + + /// The type of the step which determines the styling of its components + /// and whether steps are interactive. + final ZetaStepType type; +} + +/// The type of a [ZetaStep] which is used to control the style of the circle and text. +enum ZetaStepType { + /// A step that is currently selected with primary color icon + enabled, + + /// A step that displays a tick icon in its circle. + complete, + + /// A step that is disabled and does not to react to taps. + disabled, +} + +/// Defines the [ZetaStepper]'s main axis. +enum ZetaStepperType { + /// A vertical layout of the steps with their content in-between the titles. + vertical, + + /// A horizontal layout of the steps with their content below the titles. + horizontal, +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 6ee239cc..468ce203 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -32,6 +32,7 @@ export 'src/components/progress/progress_bar.dart'; export 'src/components/progress/progress_circle.dart'; export 'src/components/radio/radio.dart'; export 'src/components/snack_bar/snack_bar.dart'; +export 'src/components/stepper/stepper.dart'; export 'src/components/switch/zeta_switch.dart'; export 'src/components/tabs/tab.dart'; export 'src/components/tabs/tab_bar.dart';