diff --git a/.gitignore b/.gitignore index 0299f8f..d757c63 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ pubspec.lock **/doc/api/ .dart_tool/ build/ + +test_example/ \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 739583b..3ec8ba5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:icon_gallery/icon_gallery.dart'; +import 'package:icon_gallery/widget/icon_gallery_style.dart'; void main() { runApp(const MyApp()); @@ -8,27 +9,11 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), @@ -40,15 +25,6 @@ class MyApp extends StatelessWidget { class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - final String title; @override @@ -56,6 +32,8 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { + late TextEditingController searchBarController; + late List iconList1; late List iconList2; late List iconList3; @@ -64,8 +42,12 @@ class _MyHomePageState extends State { late SectionItem sectionItem2; late SectionItem sectionItem3; + late IconGalleryStyle style; + @override void initState() { + searchBarController = TextEditingController(); + iconList1 = const [ IconDataItem(value: Icons.ac_unit, name: 'Ac Unit'), IconDataItem(value: Icons.access_alarm, name: 'Access Alarm'), @@ -110,9 +92,22 @@ class _MyHomePageState extends State { items: iconList3, ); + style = IconGalleryStyle( + gridViewMaxCrossAxisExtent: 40, + itemColor: Colors.black, + itemSize: 30, + sectionPadding: const EdgeInsets.symmetric(vertical: 10), + ); + super.initState(); } + @override + void dispose() { + searchBarController.dispose(); + super.dispose(); + } + String selectedIcon = ''; IconItem? selectedIconValue; @override @@ -123,34 +118,44 @@ class _MyHomePageState extends State { title: Text(widget.title), ), body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'selected Item is :$selectedIcon ', ), + const SizedBox( + height: 30, + ), SizedBox( height: 400, + width: MediaQuery.sizeOf(context).width * .5, child: IconGallery( - selectedIcon: selectedIconValue, - sections: [ - sectionItem1, - sectionItem2, - sectionItem3, - ], - onIconSelected: (icon) { - setState(() { - selectedIconValue = icon; - debugPrint('selected icon is $icon'); - selectedIcon = selectedIconValue!.name; - }); - }), + style: style, + searchBarController: searchBarController, + selectedIcon: selectedIconValue, + sections: [ + sectionItem1, + sectionItem2, + sectionItem3, + ], + onIconSelected: (icon) { + setState(() { + selectedIconValue = icon; + debugPrint('selected icon is $icon'); + selectedIcon = selectedIconValue!.name; + }); + }, + ), ), - TextButton(onPressed: () {}, child: const Text('Show As Dialog')), TextButton( - onPressed: () {}, child: const Text('Show As BottomSheet')), + onPressed: () {}, + child: const Text('Show As Dialog'), + ), + TextButton( + onPressed: () {}, + child: const Text('Show As BottomSheet'), + ), ], ), ), diff --git a/lib/icon_gallery.dart b/lib/icon_gallery.dart index 31cce8e..fbd9204 100644 --- a/lib/icon_gallery.dart +++ b/lib/icon_gallery.dart @@ -1,5 +1,5 @@ -export 'package:icon_gallery/widget/gallery_widget.dart'; -export 'package:icon_gallery/model/type/icon_item.dart'; export 'package:icon_gallery/model/section_item.dart'; export 'package:icon_gallery/model/type/icon_data_item.dart'; +export 'package:icon_gallery/model/type/icon_item.dart'; export 'package:icon_gallery/model/type/svg_item.dart'; +export 'package:icon_gallery/widget/gallery_widget.dart'; diff --git a/lib/model/type/icon_item.dart b/lib/model/type/icon_item.dart index 7874b7d..f02430e 100644 --- a/lib/model/type/icon_item.dart +++ b/lib/model/type/icon_item.dart @@ -13,7 +13,7 @@ abstract class IconItem with EquatableMixin { Widget build( BuildContext context, { - double size = 24, + double? size = 24, Color? color, BoxFit? fit, }); diff --git a/lib/model/type/image_assets_item.dart b/lib/model/type/image_assets_item.dart new file mode 100644 index 0000000..0ce71a3 --- /dev/null +++ b/lib/model/type/image_assets_item.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:icon_gallery/model/type/icon_item.dart'; + +class ImageAssetsItem extends IconItem { + ImageAssetsItem({ + required String imagePath, + required super.name, + }) : super(value: imagePath); + + @override + Widget build( + BuildContext context, { + /// The size of the rendered image. Defaults to 24. + double? size = 24, + Color? color, + + /// The fit mode for the image. Defaults to BoxFit.none. + BoxFit? fit, + }) { + return Image.asset( + value, + fit: fit ?? BoxFit.none, + width: size, + height: size, + ); + } +} diff --git a/lib/model/type/image_network_item.dart b/lib/model/type/image_network_item.dart new file mode 100644 index 0000000..9133585 --- /dev/null +++ b/lib/model/type/image_network_item.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:icon_gallery/model/type/icon_item.dart'; + +class ImageNetworkItem extends IconItem { + ImageNetworkItem({ + required String url, + required super.name, + }) : super( + value: url, + ); + + @override + Widget build( + BuildContext context, { + /// The size of the rendered image. Defaults to 24. + double? size = 24, + Color? color, + + /// The fit mode for the image. Defaults to BoxFit.none. + BoxFit? fit, + }) { + return Image.network( + value, + fit: fit ?? BoxFit.none, + width: size, + height: size, + ); + } +} diff --git a/lib/src/temp/dialog.dart b/lib/src/temp/dialog.dart index aaab830..894df46 100644 --- a/lib/src/temp/dialog.dart +++ b/lib/src/temp/dialog.dart @@ -17,7 +17,7 @@ Future?> showIconGalleryDialog({ builder: (context) { return AlertDialog( title: const Text('Select an Icon'), - content: IconGalleryTemp( + content: IconGallery( items: items, selectedItem: selectedItem, // TODO(mahmoud): I'm not sure that we have to pop the navigator here or not diff --git a/lib/src/temp/gallery.dart b/lib/src/temp/gallery.dart index 7aa6629..94cfe80 100644 --- a/lib/src/temp/gallery.dart +++ b/lib/src/temp/gallery.dart @@ -3,6 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +typedef SearchFieldBuilder = Widget Function( + BuildContext context, + TextEditingController controller, + FocusNode? focusNode, +); + typedef GalleryFilterItemBuilder = List> Function( List> items, String filter, @@ -24,58 +30,277 @@ class IconValue { }); } -class IconGalleryTemp extends StatelessWidget { - IconGalleryTemp({ +class IconGallery extends StatefulWidget { + IconGallery({ super.key, required List> items, + this.titleWidget, + this.searchBarBuilder, + this.searchBarController, + this.maxCrossAxisExtent = 200, + this.gridPadding = EdgeInsets.zero, + this.baseWidgetPadding = EdgeInsets.zero, + this.itemColor = Colors.black, + this.itemSize = 20, this.selectedItem, this.onItemSelected, + this.filterOnChanged, GalleryFilterItemBuilder? itemFilterBuilder, GalleryItemWidgetBuilder? itemWidgetBuilder, + this.selectedItemColor, + this.backgroundColor, + this.foregroundColor, }) : _items = items { itemFilter = itemFilterBuilder ?? defaultFilterItemBuilder; // TODO(alireza): check if the is not a common type we should throw an error to tell the user to provide the [widgetBuilder] function - _widgetBuilder = itemWidgetBuilder ?? - widgetBuilderFactoryExample; // ?? factoryDesignPatter to make the default widget builder; + _widgetBuilder = + itemWidgetBuilder; // ?? factoryDesignPatter to make the default widget builder; } final List> _items; final T? selectedItem; final ValueChanged? onItemSelected; + + final ValueChanged? filterOnChanged; + late final GalleryItemWidgetBuilder? _widgetBuilder; late final GalleryFilterItemBuilder itemFilter; - // TODO(mahmoud): add other styling options like Color, Size, etc. + final Widget? titleWidget; + + final SearchFieldBuilder? searchBarBuilder; + final TextEditingController? searchBarController; + + final double maxCrossAxisExtent; + final EdgeInsets gridPadding; + final EdgeInsets baseWidgetPadding; + + final double itemSize; + final Color itemColor; + final Color? selectedItemColor; + + final Color? backgroundColor; + final Color? foregroundColor; static List> defaultFilterItemBuilder( - List> items, String filter) { - return items - .where((element) => - element.name.toLowerCase().contains(filter.toLowerCase())) - .toList(); + List> items, + String filter, + ) => + items + .where( + (item) => item.name.toLowerCase().contains(filter.toLowerCase()), + ) + .toList(); + + @override + State> createState() => _IconGalleryState(); +} + +class _IconGalleryState extends State> { + late TextEditingController _searchBarController; + + GalleryItemWidgetBuilder? _widgetBuilder; + List>? _filteredItems; + + @override + void initState() { + super.initState(); + _searchBarController = + widget.searchBarController ?? TextEditingController(); + _searchBarController.addListener(_onSearchFieldChanged); + + _widgetBuilder = widget._widgetBuilder ?? widgetBuilderFactoryExample; + + _filteredItems = + widget.itemFilter(widget._items, _searchBarController.text); + } + + @override + void didUpdateWidget(covariant IconGallery oldWidget) { + super.didUpdateWidget(oldWidget); + + _updateTextEditingController( + oldWidget.searchBarController, + widget.searchBarController, + ); + } + + @override + void dispose() { + _dismissController(_searchBarController); + + super.dispose(); } @override Widget build(BuildContext context) { - // TODO(mahmoud): implement the gallery widget - throw UnimplementedError(); + final titleWidget = widget.titleWidget ?? + Text( + 'Icons', + style: Theme.of(context).textTheme.headlineLarge, + ); + + final searchField = widget.searchBarBuilder?.call( + context, + _searchBarController, + null, + ) ?? + DefaultSearchField(controller: _searchBarController); + + final iconsGrid = GridView.extent( + padding: widget.gridPadding, + maxCrossAxisExtent: widget.maxCrossAxisExtent, + children: _childrenBuilder(), + ); + + return Padding( + padding: widget.baseWidgetPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleWidget, + const SizedBox(height: 20), + searchField, + const SizedBox(height: 20), + Expanded(child: iconsGrid), + ], + ), + ); + } + + void _dismissController(TextEditingController controller) { + controller.removeListener(_onSearchFieldChanged); + + /// If the controller is provided by the user we are not responsible for disposing it + /// otherwise if we created it we should dispose it + if (widget.searchBarController == null) { + controller.dispose(); + } + } + + void _updateTextEditingController( + TextEditingController? old, TextEditingController? current) { + if ((old == null && current == null) || old == current) { + return; + } + if (old == null) { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController.dispose(); + _searchBarController = current!; + } else if (current == null) { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController = TextEditingController(); + } else { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController = current; + } + _searchBarController.addListener(_onSearchFieldChanged); + } + + List _childrenBuilder() { + List> localItems = _filteredItems ?? widget._items; + + return localItems.map((e) => _widgetBuilder!(context, e)).toList(); + } + + void _onSearchFieldChanged() { + final searchValue = _searchBarController.text.toLowerCase(); + setState(() { + _filteredItems = widget.itemFilter(widget._items, searchValue); + }); + } + + Widget widgetBuilderFactoryExample( + BuildContext context, + IconValue value, + ) { + final color = widget.itemColor; + final iconSize = widget.itemSize; + final width = iconSize; + final height = iconSize; + + return switch (value.value.runtimeType) { + IconData => Icon( + value.value as IconData, + color: color, + size: iconSize, + ), + AssetImage => Image( + image: value.value as AssetImage, + width: width, + height: height, + ), + MemoryImage => Image( + image: value.value as MemoryImage, + width: width, + height: height, + ), + FileImage => Image( + image: value.value as FileImage, + width: width, + height: height, + ), + NetworkImage => Image( + image: value.value as NetworkImage, + width: width, + height: height, + ), + SvgAssetLoader => SvgPicture( + value.value as SvgAssetLoader, + width: width, + height: height, + ), + SvgStringLoader => SvgPicture( + value.value as SvgStringLoader, + width: width, + height: height, + ), + SvgNetworkLoader => SvgPicture( + value.value as SvgNetworkLoader, + width: width, + height: height, + ), + SvgFileLoader => SvgPicture( + value.value as SvgFileLoader, + width: width, + height: height, + ), + SvgBytesLoader => SvgPicture( + value.value as SvgBytesLoader, + width: width, + height: height, + ), + _ => Container( + color: color, + width: width, + height: height, + ), + }; } } -Widget widgetBuilderFactoryExample( - BuildContext context, IconValue value) { - return switch (value.value.runtimeType) { - IconData => Icon(value.value as IconData), - AssetImage => Image(image: value.value as AssetImage), - MemoryImage => Image(image: value.value as MemoryImage), - FileImage => Image(image: value.value as FileImage), - NetworkImage => Image(image: value.value as NetworkImage), - SvgAssetLoader => SvgPicture(value.value as SvgAssetLoader), - SvgStringLoader => SvgPicture(value.value as SvgStringLoader), - SvgNetworkLoader => SvgPicture(value.value as SvgNetworkLoader), - SvgFileLoader => SvgPicture(value.value as SvgFileLoader), - SvgBytesLoader => SvgPicture(value.value as SvgBytesLoader), - _ => Container(), - }; +class DefaultSearchField extends StatelessWidget { + const DefaultSearchField({ + super.key, + required this.controller, + }); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + decoration: const InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + hintText: 'Type for filter', + prefixIcon: Icon(Icons.search), + ), + ); + } } diff --git a/lib/widget/gallery_widget.dart b/lib/widget/gallery_widget.dart index 6ee7b14..89b9a1d 100644 --- a/lib/widget/gallery_widget.dart +++ b/lib/widget/gallery_widget.dart @@ -1,10 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:icon_gallery/model/type/icon_item.dart'; import 'package:icon_gallery/model/section_item.dart'; +import 'package:icon_gallery/model/type/icon_item.dart'; +import 'package:icon_gallery/widget/icon_gallery_style.dart'; + +typedef SearchFieldBuilder = Widget Function( + BuildContext context, + TextEditingController controller, + FocusNode? focusNode, +); typedef OnIconSelected = void Function(IconItem selectedIcon); class IconGallery extends StatefulWidget { + final SearchFieldBuilder? searchBarBuilder; + final TextEditingController? searchBarController; + final IconGalleryStyle style; final List sections; final IconItem? selectedIcon; final OnIconSelected onIconSelected; @@ -12,16 +22,20 @@ class IconGallery extends StatefulWidget { const IconGallery({ super.key, required this.sections, - this.selectedIcon, required this.onIconSelected, + required this.style, + this.selectedIcon, + this.searchBarBuilder, + this.searchBarController, }); IconGallery.list({ Key? key, - String title = 'Icons', required List icons, - IconItem? selectedIcon, required OnIconSelected onIconSelected, + required IconItem? selectedIcon, + required double gridViewMaxCrossAxisExtent, + String title = 'Icons', }) : this( key: key, sections: [ @@ -30,6 +44,9 @@ class IconGallery extends StatefulWidget { items: icons, ), ], + style: IconGalleryStyle( + gridViewMaxCrossAxisExtent: gridViewMaxCrossAxisExtent, + ), selectedIcon: selectedIcon, onIconSelected: onIconSelected, ); @@ -39,41 +56,120 @@ class IconGallery extends StatefulWidget { } class _IconGalleryState extends State { + late TextEditingController _searchBarController; + late List filteredItem; + + @override + void initState() { + super.initState(); + + filteredItem = widget.sections; + + _searchBarController = + widget.searchBarController ?? TextEditingController(); + + _searchBarController.addListener(_onSearchFieldChanged); + } + + @override + void didUpdateWidget(covariant IconGallery oldWidget) { + super.didUpdateWidget(oldWidget); + + _updateTextEditingController( + oldWidget.searchBarController, + widget.searchBarController, + ); + } + + @override + void dispose() { + _disposeController(); + super.dispose(); + } + + _disposeController() { + if (widget.searchBarController == null) { + _searchBarController.dispose(); + } + } + + void _updateTextEditingController( + TextEditingController? old, + TextEditingController? current, + ) { + if ((old == null && current == null) || old == current) { + return; + } + if (old == null) { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController.dispose(); + _searchBarController = current!; + } else if (current == null) { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController = TextEditingController(); + } else { + _searchBarController.removeListener(_onSearchFieldChanged); + _searchBarController = current; + } + _searchBarController.addListener(_onSearchFieldChanged); + } + + _onSearchFieldChanged() { + final searchValue = _searchBarController.text.toLowerCase(); + setState(() { + filteredItem = widget.sections + .map((section) { + final filteredItems = section.items + .where((item) => item.name.toLowerCase().contains(searchValue)) + .toList(); + return section.copyWith(items: filteredItems); + }) + .where((section) => section.items.isNotEmpty) + .toList(); + }); + } + @override Widget build(BuildContext context) { + final searchField = widget.searchBarBuilder?.call( + context, + _searchBarController, + null, + ) ?? + DefaultSearchField(controller: _searchBarController); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + searchField, Expanded( child: ListView.builder( - itemCount: widget.sections.length, + itemCount: filteredItem.length, itemBuilder: (context, sectionIndex) { - final section = widget.sections[sectionIndex]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text(section.title, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - ), - Wrap( - children: section.items.map((icon) { - final isSelected = widget.selectedIcon == icon; - - return InkWell( - child: IconHolder( - isSelected: isSelected, - icon: icon, - ), - onTap: () { - widget.onIconSelected(icon); - }, - ); - }).toList(), - ), - ], + final section = filteredItem[sectionIndex]; + return Padding( + padding: widget.style.sectionPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: widget.style.sectionTitlePadding, + child: Text( + section.title, + style: widget.style.sectionTitleStyle, + ), + ), + GridView.extent( + shrinkWrap: true, + padding: widget.style.gridViewPadding, + maxCrossAxisExtent: + widget.style.gridViewMaxCrossAxisExtent, + children: _itemBuilder( + filteredItem[sectionIndex].items, + ), + ), + ], + ), ); }, ), @@ -81,6 +177,24 @@ class _IconGalleryState extends State { ], ); } + + List _itemBuilder(List items) { + return items + .map( + (e) => InkWell( + onTap: () { + widget.onIconSelected(e); + }, + child: e.build( + context, + color: widget.style.itemColor, + fit: widget.style.fit, + size: widget.style.itemSize, + ), + ), + ) + .toList(); + } } class IconHolder extends StatelessWidget { @@ -110,3 +224,28 @@ class IconHolder extends StatelessWidget { ); } } + +class DefaultSearchField extends StatelessWidget { + const DefaultSearchField({ + super.key, + required this.controller, + }); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + decoration: const InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + hintText: 'Type for filter', + prefixIcon: Icon(Icons.search), + ), + ); + } +} diff --git a/lib/widget/icon_gallery_style.dart b/lib/widget/icon_gallery_style.dart new file mode 100644 index 0000000..f2e975e --- /dev/null +++ b/lib/widget/icon_gallery_style.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class IconGalleryStyle { + // Size and gaps + final EdgeInsetsGeometry gridViewPadding; + final EdgeInsetsGeometry sectionTitlePadding; + final EdgeInsetsGeometry sectionPadding; + + final double? itemSize; + + // Text Theme + final TextStyle? sectionTitleStyle; + final TextStyle? searchBarHintStyle; + + // colors + final Color? itemColor; + + final BoxFit? fit; + + // gridView styles + final double gridViewMaxCrossAxisExtent; + + IconGalleryStyle({ + required this.gridViewMaxCrossAxisExtent, + this.itemSize, + this.gridViewPadding = EdgeInsets.zero, + this.sectionPadding = EdgeInsets.zero, + this.sectionTitlePadding = EdgeInsets.zero, + this.sectionTitleStyle = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color.fromARGB(143, 0, 0, 0), + ), + this.searchBarHintStyle, + this.itemColor, + this.fit, + }); +}