From 54779ae828159ae0f5f9cb783e94514d9474fe45 Mon Sep 17 00:00:00 2001 From: markkiepe Date: Wed, 3 Apr 2024 16:16:34 +0200 Subject: [PATCH] feat: add nested category functionality --- lib/flutter_nested_categories.dart | 9 + lib/src/category.dart | 31 ++++ lib/src/category_header_styling.dart | 18 ++ lib/src/category_list.dart | 243 +++++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 lib/flutter_nested_categories.dart create mode 100644 lib/src/category.dart create mode 100644 lib/src/category_header_styling.dart create mode 100644 lib/src/category_list.dart diff --git a/lib/flutter_nested_categories.dart b/lib/flutter_nested_categories.dart new file mode 100644 index 0000000..4530209 --- /dev/null +++ b/lib/flutter_nested_categories.dart @@ -0,0 +1,9 @@ +/// Flutter Nested Categories +/// +/// A Flutter package that allows you to create nested categories that +/// are very easy to use and customize. +library flutter_nested_categories; + +export "src/category.dart"; +export "src/category_header_styling.dart"; +export "src/category_list.dart"; diff --git a/lib/src/category.dart b/lib/src/category.dart new file mode 100644 index 0000000..d99cb17 --- /dev/null +++ b/lib/src/category.dart @@ -0,0 +1,31 @@ +import "package:flutter/widgets.dart"; + +/// One of the categories in the list. Each category can contain content, +/// which can be an empty list, but also nested categories. The nested +/// categories are the same type as the parent category which allows for +/// infinite nesting. +class Category { + const Category({ + /// The name of the category. + required this.name, + + /// The content of the category. This can be anything, but is usually + /// a list of widgets. + /// + /// Default is an empty list. + /// + /// If the category has nested categories, it will show this content + /// before the nested categories. + this.content = const [], + + /// The nested categories of this category. This can be an empty list + /// if there are no nested categories. + /// + /// Default is an empty list. + this.nestedCategories = const [], + }); + + final String name; + final List nestedCategories; + final List content; +} diff --git a/lib/src/category_header_styling.dart b/lib/src/category_header_styling.dart new file mode 100644 index 0000000..61bf568 --- /dev/null +++ b/lib/src/category_header_styling.dart @@ -0,0 +1,18 @@ +import "package:flutter/widgets.dart"; + +/// This class is used to style the headers of the categories in the list. +/// The headers are the names of the categories. +class CategoryHeaderStyling { + const CategoryHeaderStyling({ + /// The styles for the headers. The key is the depth of the category. + /// The value is the text style for the header. + required this.headerStyles, + + /// The default style for the headers. This will be used if the depth + /// is not found in the [headerStyles]. + this.defaultStyle, + }); + + final Map headerStyles; + final TextStyle? defaultStyle; +} diff --git a/lib/src/category_list.dart b/lib/src/category_list.dart new file mode 100644 index 0000000..ad7b6db --- /dev/null +++ b/lib/src/category_list.dart @@ -0,0 +1,243 @@ +import "package:flutter/material.dart"; +import "package:flutter_nested_categories/flutter_nested_categories.dart"; + +/// This widget displays a list of categories. Each category can contain +/// content, which can be an empty list, but also nested categories. The +/// nested categories are the same type as the parent category which allows +/// for infinite nesting. +/// +/// The content of the categories is displayed in the order it is given in +/// the list. If a category has nested categories, the content of the nested +/// categories is displayed after the content of the parent category. +class CategoryList extends StatelessWidget { + const CategoryList({ + /// The content of the category list. This is a list of categories. + required this.content, + + /// Optional header styling for the categories. This will be applied to + /// the name of the categories. If not set, the default text style will + /// be used. + this.headerStyling, + + /// Configure if the category header should be centered. + /// + /// Default is false. + this.headerCentered = false, + + /// Optional custom title widget for the category list. This will be + /// displayed at the top of the list. If set, the text title will be + /// ignored. + this.customTitle, + + /// Optional title for the category list. This will be displayed at the + /// top of the list. + this.title, + + /// Optional title style for the title of the category list. This will + /// be applied to the title of the category list. If not set, the default + /// text style will be used. + this.titleStyle, + + /// Configure if the title should be centered. + /// + /// Default is false. + this.titleCentered = false, + + /// Configure if the category should be collapsible. + /// + /// Default is true. + this.isCategoryCollapsible = true, + + /// The depth of the category list. This is used to keep track of + /// the depth of the categories. This is used internally and should + /// not be set manually. + this.categoryDepth = 0, + + /// The key for this widget. + super.key, + }); + + final String? title; + final Widget? customTitle; + final TextStyle? titleStyle; + final bool titleCentered; + + final CategoryHeaderStyling? headerStyling; + final bool headerCentered; + + final bool isCategoryCollapsible; + final int categoryDepth; + + final List content; + + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null || customTitle != null) + titleCentered + ? Center( + child: _CategoryTitle( + title: title, + titleStyle: titleStyle, + customTitle: customTitle, + ), + ) + : _CategoryTitle( + title: title, + titleStyle: titleStyle, + customTitle: customTitle, + ), + ...content.map( + (category) => _CategoryColumn( + category: category, + headerCentered: headerCentered, + headerStyling: headerStyling, + collapsible: isCategoryCollapsible, + categoryDepth: categoryDepth, + ), + ), + ].toList(), + ), + ); +} + +class _CategoryTitle extends StatelessWidget { + const _CategoryTitle({ + this.customTitle, + this.title, + this.titleStyle, + }) : assert( + customTitle != null || title != null, + "Either customTitle or title must be set.", + ); + + final String? title; + final Widget? customTitle; + final TextStyle? titleStyle; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var styleOfTitle = titleStyle ?? theme.textTheme.titleLarge; + + if (customTitle != null) { + return customTitle!; + } else { + return Text( + title!, + style: styleOfTitle, + ); + } + } +} + +class _CategoryColumn extends StatefulWidget { + const _CategoryColumn({ + required this.category, + required this.categoryDepth, + required this.headerCentered, + this.headerStyling, + this.collapsible = true, + }); + + final Category category; + final CategoryHeaderStyling? headerStyling; + final bool headerCentered; + final int categoryDepth; + final bool collapsible; + + @override + State<_CategoryColumn> createState() => _CategoryColumnState(); +} + +class _CategoryColumnState extends State<_CategoryColumn> { + bool _isExpanded = true; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var styleOfCategory = + widget.headerStyling?.headerStyles[widget.categoryDepth] ?? + widget.headerStyling?.defaultStyle ?? + theme.textTheme.titleMedium ?? + const TextStyle(); + + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.collapsible) _buildCollapsibleHeader(styleOfCategory), + if (!widget.collapsible) _buildNonCollapsibleHeader(styleOfCategory), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: const SizedBox(width: double.infinity), + secondChild: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.category.content.isNotEmpty) + ...widget.category.content, + if (widget.category.nestedCategories.isNotEmpty) + _buildNestedCategoryList(), + ], + ), + crossFadeState: _isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), + ], + ), + ); + } + + Widget _buildCollapsibleHeader(TextStyle styleOfCategory) => InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Row( + mainAxisAlignment: widget.headerCentered + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + ExpandIcon( + onPressed: (value) => setState(() { + _isExpanded = !_isExpanded; + }), + isExpanded: _isExpanded, + padding: EdgeInsets.zero, + ), + const SizedBox(width: 8), + Text( + widget.category.name, + style: styleOfCategory, + ), + ], + ), + ); + + Widget _buildNonCollapsibleHeader(TextStyle styleOfCategory) => + widget.headerCentered + ? Center( + child: Text( + widget.category.name, + style: styleOfCategory, + ), + ) + : Text( + widget.category.name, + style: styleOfCategory, + ); + + Widget _buildNestedCategoryList() => CategoryList( + content: widget.category.nestedCategories, + headerCentered: widget.headerCentered, + headerStyling: widget.headerStyling, + isCategoryCollapsible: widget.collapsible, + categoryDepth: widget.categoryDepth + 1, + ); +}