diff --git a/docs/app/docs/data/tree/page.mdx b/docs/app/docs/data/tree/page.mdx new file mode 100644 index 000000000..8b3d6299e --- /dev/null +++ b/docs/app/docs/data/tree/page.mdx @@ -0,0 +1,269 @@ +import { Callout } from 'nextra/components'; +import { Tabs } from 'nextra/components'; +import { Widget } from '@/components/demo/widget.tsx'; +import LinkBadge from '@/components/ui/link-badge/link-badge.tsx'; +import LinkBadgeGroup from '@/components/ui/link-badge/link-badge-group.tsx'; + +# Tree + +A hierarchical tree widget for displaying nested data with visual connecting lines. + + + + + + + + + + + ```dart copy + FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Apple'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Red Apple'), + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Green Apple'), + onPress: () {}, + ), + ], + ), + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Banana'), + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Cherry'), + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('Date'), + selected: true, + onPress: () {}, + ), + ], + ); + ``` + + + +## Usage + +### Basic Tree + +A basic tree structure with collapsible items. + + + + + + + ```dart copy + FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Folder 1'), + children: [ + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('File 1.1'), + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('File 1.2'), + onPress: () {}, + ), + ], + ), + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('File 2'), + onPress: () {}, + ), + ], + ); + ``` + + + +### Nested Tree + +Trees support arbitrary nesting depth with visual connecting lines. + + + + + + + ```dart copy + FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 1'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 2'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 3'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('Deep File'), + onPress: () {}, + ), + ], + ), + ], + ), + ], + ), + ], + ); + ``` + + + +### Selected Item + +Tree items can be marked as selected. + + + + + + + ```dart copy + FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Item 1'), + selected: true, + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Item 2'), + onPress: () {}, + ), + ], + ); + ``` + + + +## Examples + +### File Explorer + +A file explorer-style tree with folders and files. + +```dart +FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('src'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('components'), + children: [ + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('button.dart'), + onPress: () {}, + ), + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('card.dart'), + onPress: () {}, + ), + ], + ), + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('main.dart'), + selected: true, + onPress: () {}, + ), + ], + ), + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('test'), + children: [ + FTreeItem( + icon: const Icon(FIcons.file), + label: const Text('button_test.dart'), + onPress: () {}, + ), + ], + ), + ], +); +``` + +## Customization + +### Custom Styling + +You can customize the tree appearance using `FTreeStyle`. + +```dart +FTree( + style: (style) => style.copyWith( + indentWidth: 30, + spacing: 4, + itemStyle: (itemStyle) => itemStyle.copyWith( + borderRadius: BorderRadius.circular(8), + ), + ), + children: [ + // ... tree items + ], +); +``` + +### Custom Line Style + +Customize the connecting line style. + +```dart +FTree( + style: (style) => style.copyWith( + itemStyle: (itemStyle) => itemStyle.copyWith( + lineStyle: (lineStyle) => lineStyle.copyWith( + color: Colors.blue, + width: 2, + dashPattern: [4, 4], // Dashed line + ), + ), + ), + children: [ + // ... tree items + ], +); +``` diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md index d05a113e9..5d0f424a0 100644 --- a/forui/CHANGELOG.md +++ b/forui/CHANGELOG.md @@ -1,3 +1,11 @@ +## Next + +### `FTree` +- Add `FTree` - A hierarchical tree widget for displaying nested data with visual connecting lines. +- Add `FTreeItem` - Individual tree node widget with expand/collapse support. +- Add `FTreeController`. +- Add `FTreeStyle`, `FTreeItemStyle`, `FTreeLineStyle`, and `FTreeItemMotion`. + ## 0.16.0 ### Better Generated Documentation diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index c0577aa74..ca7377d77 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -45,5 +45,6 @@ export 'widgets/tabs.dart'; export 'widgets/text_field.dart'; export 'widgets/tile.dart'; export 'widgets/time_picker.dart'; +export 'widgets/tree.dart'; export 'widgets/time_field.dart'; export 'widgets/tooltip.dart'; diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart index bd503ab9f..9e54a0d93 100644 --- a/forui/lib/src/theme/theme_data.dart +++ b/forui/lib/src/theme/theme_data.dart @@ -535,6 +535,17 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { @override final FTileGroupStyle tileGroupStyle; + /// The tree style. + /// + /// ## CLI + /// To generate and customize this style: + /// + /// ```shell + /// dart run forui style create tree + /// ``` + @override + final FTreeStyle treeStyle; + /// The time field's style. /// /// ## CLI @@ -622,6 +633,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { FTextFieldStyle? textFieldStyle, FTileStyle? tileStyle, FTileGroupStyle? tileGroupStyle, + FTreeStyle? treeStyle, FTimeFieldStyle? timeFieldStyle, FTimePickerStyle? timePickerStyle, FTooltipStyle? tooltipStyle, @@ -691,6 +703,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { textFieldStyle: textFieldStyle ?? FTextFieldStyle.inherit(colors: colors, typography: typography, style: style), tileStyle: tileStyle ?? FTileStyle.inherit(colors: colors, typography: typography, style: style), tileGroupStyle: tileGroupStyle ?? FTileGroupStyle.inherit(colors: colors, typography: typography, style: style), + treeStyle: treeStyle ?? FTreeStyle.inherit(colors: colors, typography: typography, style: style), timeFieldStyle: timeFieldStyle ?? FTimeFieldStyle.inherit(colors: colors, typography: typography, style: style), timePickerStyle: timePickerStyle ?? FTimePickerStyle.inherit(colors: colors, typography: typography, style: style), @@ -751,6 +764,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { textFieldStyle: a.textFieldStyle.lerp(b.textFieldStyle, t), tileStyle: a.tileStyle.lerp(b.tileStyle, t), tileGroupStyle: a.tileGroupStyle.lerp(b.tileGroupStyle, t), + treeStyle: a.treeStyle.lerp(b.treeStyle, t), timeFieldStyle: a.timeFieldStyle.lerp(b.timeFieldStyle, t), timePickerStyle: a.timePickerStyle.lerp(b.timePickerStyle, t), tooltipStyle: a.tooltipStyle.lerp(b.tooltipStyle, t), @@ -811,6 +825,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { required this.textFieldStyle, required this.tileStyle, required this.tileGroupStyle, + required this.treeStyle, required this.timeFieldStyle, required this.timePickerStyle, required this.tooltipStyle, @@ -1311,6 +1326,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { FTextFieldStyle Function(FTextFieldStyle style)? textFieldStyle, FTileStyle Function(FTileStyle style)? tileStyle, FTileGroupStyle Function(FTileGroupStyle style)? tileGroupStyle, + FTreeStyle Function(FTreeStyle style)? treeStyle, FTimeFieldStyle Function(FTimeFieldStyle style)? timeFieldStyle, FTimePickerStyle Function(FTimePickerStyle style)? timePickerStyle, FTooltipStyle Function(FTooltipStyle style)? tooltipStyle, @@ -1376,6 +1392,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions { textFieldStyle: textFieldStyle != null ? textFieldStyle(this.textFieldStyle) : this.textFieldStyle, tileStyle: tileStyle != null ? tileStyle(this.tileStyle) : this.tileStyle, tileGroupStyle: tileGroupStyle != null ? tileGroupStyle(this.tileGroupStyle) : this.tileGroupStyle, + treeStyle: treeStyle != null ? treeStyle(this.treeStyle) : this.treeStyle, timeFieldStyle: timeFieldStyle != null ? timeFieldStyle(this.timeFieldStyle) : this.timeFieldStyle, timePickerStyle: timePickerStyle != null ? timePickerStyle(this.timePickerStyle) : this.timePickerStyle, tooltipStyle: tooltipStyle != null ? tooltipStyle(this.tooltipStyle) : this.tooltipStyle, diff --git a/forui/lib/src/widgets/tree/tree.dart b/forui/lib/src/widgets/tree/tree.dart new file mode 100644 index 000000000..40008426e --- /dev/null +++ b/forui/lib/src/widgets/tree/tree.dart @@ -0,0 +1,112 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; + +part 'tree.design.dart'; + +/// A hierarchical tree widget for displaying nested data with visual connecting lines. +/// +/// The [FTree] widget is useful for creating tree-like structures such as file explorers, organization charts, +/// or any hierarchical data representation. +/// +/// See: +/// * https://forui.dev/docs/data/tree for working examples. +/// * [FTreeStyle] for customizing a tree's appearance. +/// * [FTreeItem] for creating individual tree nodes. +class FTree extends StatelessWidget { + /// The style. + /// + /// ## CLI + /// To generate and customize this style: + /// + /// ```shell + /// dart run forui style create tree + /// ``` + final FTreeStyle Function(FTreeStyle style)? style; + + /// The root-level tree items. + final List children; + + /// Creates a [FTree]. + const FTree({required this.children, this.style, super.key}); + + @override + Widget build(BuildContext context) { + final style = this.style?.call(context.theme.treeStyle) ?? context.theme.treeStyle; + final textDirection = Directionality.of(context); + + return FTreeData( + style: style, + child: Column( + crossAxisAlignment: textDirection == TextDirection.rtl ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: style.spacing, + children: children, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style)); + } +} + +/// A [FTree]'s data. +@internal +class FTreeData extends InheritedWidget { + /// Returns the [FTreeData] of the [FTree] in the given [context]. + /// + /// ## Contract + /// Throws [AssertionError] if there is no ancestor [FTree] in the given [context]. + static FTreeData? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + /// The [FTree]'s style. + final FTreeStyle style; + + /// Creates a [FTreeData]. + const FTreeData({required this.style, required super.child, super.key}); + + @override + bool updateShouldNotify(FTreeData old) => style != old.style; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style)); + } +} + +/// A [FTree]'s style. +class FTreeStyle with Diagnosticable, _$FTreeStyleFunctions { + /// The spacing between root-level tree items. Defaults to 2. + @override + final double spacing; + + /// The indentation for each nesting level. Defaults to 20. + @override + final double indentWidth; + + /// The item's style. + @override + final FTreeItemStyle itemStyle; + + /// Creates a [FTreeStyle]. + const FTreeStyle({ + required this.itemStyle, + this.spacing = 2, + this.indentWidth = 20, + }); + + /// Creates a [FTreeStyle] that inherits its properties. + FTreeStyle.inherit({required FColors colors, required FTypography typography, required FStyle style}) + : this( + itemStyle: FTreeItemStyle.inherit(colors: colors, typography: typography, style: style), + ); +} diff --git a/forui/lib/src/widgets/tree/tree_controller.dart b/forui/lib/src/widgets/tree/tree_controller.dart new file mode 100644 index 000000000..e2e29f433 --- /dev/null +++ b/forui/lib/src/widgets/tree/tree_controller.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; + +import 'package:forui/forui.dart'; + +/// A controller that controls which [FTreeItem]s are expanded/collapsed. +/// +/// By default, [FTreeItem]s are initially collapsed unless [FTreeItem.initiallyExpanded] is set to true. +/// +/// See: +/// * https://forui.dev/docs/data/tree for working examples. +final class FTreeController extends FChangeNotifier with Diagnosticable { + final Set _expanded; + + /// Creates a [FTreeController]. + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * `expanded` contains duplicate values. + /// * `min <= expanded` is false for any value in `expanded`. + FTreeController({Set expanded = const {}}) + : assert(expanded.length == {...expanded}.length, 'expanded should not have duplicates.'), + _expanded = {...expanded}; + + /// The indices of the expanded items. + Set get expanded => {..._expanded}; + + /// Expands the item at the given [index]. + void expand(int index) { + if (_expanded.add(index)) { + notifyListeners(); + } + } + + /// Collapses the item at the given [index]. + void collapse(int index) { + if (_expanded.remove(index)) { + notifyListeners(); + } + } + + /// Toggles the expansion state of the item at the given [index]. + void toggle(int index) { + if (_expanded.contains(index)) { + collapse(index); + } else { + expand(index); + } + } + + /// Returns true if the item at the given [index] is expanded. + bool isExpanded(int index) => _expanded.contains(index); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('expanded', _expanded)); + } +} diff --git a/forui/lib/src/widgets/tree/tree_item.dart b/forui/lib/src/widgets/tree/tree_item.dart new file mode 100644 index 000000000..fd5d61b2e --- /dev/null +++ b/forui/lib/src/widgets/tree/tree_item.dart @@ -0,0 +1,599 @@ +import 'dart:ui' show lerpDouble, Color; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/tree/tree_painter.dart'; + +part 'tree_item.design.dart'; + +/// A tree item that can be expanded or collapsed to show/hide children. +/// +/// The [FTreeItem] widget is useful for creating hierarchical structures with visual connecting lines. +/// +/// See: +/// * https://forui.dev/docs/data/tree for working examples. +/// * [FTreeItemStyle] for customizing a tree item's appearance. +class FTreeItem extends StatefulWidget { + /// The tree item's style. + /// + /// ## CLI + /// To generate and customize this style: + /// + /// ```shell + /// dart run forui style create tree + /// ``` + final FTreeItemStyle Function(FTreeItemStyle style)? style; + + /// The icon to display before the label. + final Widget? icon; + + /// The main content of the item. + final Widget? label; + + /// Whether this item is currently selected. + final bool selected; + + /// Whether this item is initially expanded. Defaults to false. + final bool initiallyExpanded; + + /// {@macro forui.foundation.doc_templates.autofocus} + final bool autofocus; + + /// {@macro forui.foundation.doc_templates.focusNode} + final FocusNode? focusNode; + + /// Called when the item is pressed. + /// + /// The method will run concurrently with animations if [children] is non-null. + final VoidCallback? onPress; + + /// Called when the item is long pressed. + final VoidCallback? onLongPress; + + /// Called when the hover state changes. + final ValueChanged? onHoverChange; + + /// Called when the state changes. + final ValueChanged? onStateChange; + + /// Called when the expansion state changes. + final ValueChanged? onExpandChange; + + /// Called when the visible row count changes (including this item and all visible descendants). + final ValueChanged? _onRowCountChange; + + /// The tree item's children. + final List children; + + /// Creates a [FTreeItem]. + const FTreeItem({ + this.style, + this.icon, + this.label, + this.selected = false, + this.initiallyExpanded = false, + this.autofocus = false, + this.focusNode, + this.onPress, + this.onLongPress, + this.onHoverChange, + this.onStateChange, + this.onExpandChange, + this.children = const [], + super.key, + }) : _onRowCountChange = null; + + const FTreeItem._({ + this.style, + this.icon, + this.label, + this.selected = false, + this.initiallyExpanded = false, + this.autofocus = false, + this.focusNode, + this.onPress, + this.onLongPress, + this.onHoverChange, + this.onStateChange, + this.onExpandChange, + ValueChanged? onRowCountChange, + this.children = const [], + super.key, + }) : _onRowCountChange = onRowCountChange; + + @override + State createState() => _FTreeItemState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('style', style)) + ..add(FlagProperty('selected', value: selected, ifTrue: 'selected')) + ..add(FlagProperty('initiallyExpanded', value: initiallyExpanded, ifTrue: 'initiallyExpanded')) + ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(ObjectFlagProperty.has('onPress', onPress)) + ..add(ObjectFlagProperty.has('onLongPress', onLongPress)) + ..add(ObjectFlagProperty.has('onHoverChange', onHoverChange)) + ..add(ObjectFlagProperty.has('onStateChange', onStateChange)) + ..add(ObjectFlagProperty.has('onExpandChange', onExpandChange)) + ..add(ObjectFlagProperty.has('onRowCountChange', _onRowCountChange)) + ..add(DiagnosticsProperty('children', children)); + } +} + +class _FTreeItemState extends State with TickerProviderStateMixin { + FTreeItemStyle? _style; + AnimationController? _controller; + CurvedAnimation? _curvedReveal; + CurvedAnimation? _curvedFade; + CurvedAnimation? _curvedIconRotation; + Animation? _reveal; + Animation? _fade; + Animation? _iconRotation; + late bool _expanded; + late Map _childExpansionStates; + late Map _childRowCounts; + bool _hasNotifiedInitialRowCount = false; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + _childExpansionStates = {for (var i = 0; i < widget.children.length; i++) i: widget.children[i].initiallyExpanded}; + // Initialize with 1 row per child (will be updated when children report their actual counts) + _childRowCounts = {for (var i = 0; i < widget.children.length; i++) i: 1}; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _update(); + + // Notify parent of initial row count after first build + if (!_hasNotifiedInitialRowCount) { + _hasNotifiedInitialRowCount = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _notifyRowCountChange(); + } + }); + } + } + + @override + void didUpdateWidget(FTreeItem old) { + super.didUpdateWidget(old); + _update(); + } + + void _update() { + final treeData = FTreeData.maybeOf(context); + final inheritedStyle = treeData?.style.itemStyle ?? context.theme.treeStyle.itemStyle; + final style = widget.style?.call(inheritedStyle) ?? inheritedStyle; + + if (_style != style) { + _style = style; + _curvedIconRotation?.dispose(); + _curvedFade?.dispose(); + _curvedReveal?.dispose(); + _controller?.dispose(); + + _controller = AnimationController( + vsync: this, + value: _expanded ? 1.0 : 0.0, + duration: style.motion.expandDuration, + reverseDuration: style.motion.collapseDuration, + ); + _curvedReveal = CurvedAnimation( + curve: style.motion.expandCurve, + reverseCurve: style.motion.collapseCurve, + parent: _controller!, + ); + _curvedFade = CurvedAnimation(curve: Curves.easeIn, reverseCurve: Curves.easeOut, parent: _controller!); + _curvedIconRotation = CurvedAnimation( + curve: style.motion.iconExpandCurve, + reverseCurve: style.motion.iconCollapseCurve, + parent: _controller!, + ); + _reveal = style.motion.revealTween.animate(_curvedReveal!); + _fade = style.motion.fadeTween.animate(_curvedFade!); + _iconRotation = style.motion.iconTween.animate(_curvedIconRotation!); + + if (_expanded) { + _controller!.value = 1.0; + } + } + } + + void _toggle() { + setState(() { + _expanded = !_expanded; + widget.onExpandChange?.call(_expanded); + _notifyRowCountChange(); + if (_expanded) { + _controller!.forward(); + } else { + _controller!.reverse(); + } + }); + } + + /// Calculates and notifies parent of this item's total visible row count. + void _notifyRowCountChange() { + final rowCount = _calculateRowCount(); + widget._onRowCountChange?.call(rowCount); + } + + /// Calculates the total number of visible rows for this item including descendants. + int _calculateRowCount() { + if (!_expanded || widget.children.isEmpty) { + return 1; // Just this item + } + // This item plus all visible children + var count = 1; + for (var i = 0; i < widget.children.length; i++) { + count += _childRowCounts[i] ?? 1; + } + return count; + } + + @override + void dispose() { + _curvedIconRotation?.dispose(); + _curvedFade?.dispose(); + _curvedReveal?.dispose(); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final treeData = FTreeData.maybeOf(context); + final indentWidth = treeData?.style.indentWidth ?? context.theme.treeStyle.indentWidth; + final textDirection = Directionality.of(context); + + return Column( + crossAxisAlignment: textDirection == TextDirection.rtl ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Main item content + FTappable( + style: _style!.tappableStyle, + focusedOutlineStyle: _style!.focusedOutlineStyle, + selected: widget.selected, + autofocus: widget.autofocus, + focusNode: widget.focusNode, + onPress: widget.children.isNotEmpty + ? () { + _toggle(); + widget.onPress?.call(); + } + : widget.onPress, + onLongPress: widget.onLongPress, + onHoverChange: widget.onHoverChange, + onStateChange: widget.onStateChange, + builder: (_, states, child) => Container( + alignment: textDirection == TextDirection.rtl ? Alignment.centerRight : Alignment.centerLeft, + padding: _style!.padding, + decoration: BoxDecoration( + color: _style!.backgroundColor.resolve(states), + borderRadius: _style!.borderRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + spacing: _style!.iconSpacing, + children: [ + if (widget.children.isNotEmpty) + IconTheme( + data: _style!.expandIconStyle.resolve(states), + child: RotationTransition(turns: _iconRotation!, child: const Icon(FIcons.chevronRight)), + ) + else if (widget.icon != null) + IconTheme(data: _style!.iconStyle.resolve(states), child: widget.icon!), + if (widget.label != null) + Flexible( + child: DefaultTextStyle.merge(style: _style!.textStyle.resolve(states), child: widget.label!), + ), + ], + ), + ), + ), + // Expandable children + if (widget.children.isNotEmpty) + AnimatedBuilder( + animation: _reveal!, + builder: (_, _) => FCollapsible( + value: _reveal!.value, + child: AnimatedBuilder( + animation: _fade!, + builder: (context, child) { + // Use tracked row counts from children + final childRowCounts = [for (var i = 0; i < widget.children.length; i++) _childRowCounts[i] ?? 1]; + + return FadeTransition( + opacity: _fade!, + child: Padding( + padding: textDirection == TextDirection.rtl + ? EdgeInsets.only(right: indentWidth) + : EdgeInsets.only(left: indentWidth), + child: CustomPaint( + painter: FTreeChildrenLinePainter( + lineStyle: _style!.lineStyle, + childCount: widget.children.length, + childrenSpacing: _style!.childrenSpacing, + childRowCounts: childRowCounts, + itemPadding: _style!.padding.resolve(textDirection).left, + textDirection: textDirection, + ), + child: Column( + crossAxisAlignment: textDirection == TextDirection.rtl + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: _style!.childrenSpacing, + children: [ + for (var i = 0; i < widget.children.length; i++) + FTreeItem._( + key: widget.children[i].key, + style: widget.children[i].style, + icon: widget.children[i].icon, + label: widget.children[i].label, + selected: widget.children[i].selected, + initiallyExpanded: widget.children[i].initiallyExpanded, + autofocus: widget.children[i].autofocus, + focusNode: widget.children[i].focusNode, + onPress: widget.children[i].onPress, + onLongPress: widget.children[i].onLongPress, + onHoverChange: widget.children[i].onHoverChange, + onStateChange: widget.children[i].onStateChange, + onExpandChange: (expanded) { + setState(() { + _childExpansionStates[i] = expanded; + }); + }, + onRowCountChange: (rowCount) { + setState(() { + _childRowCounts[i] = rowCount; + }); + // Propagate the change up to our parent + _notifyRowCountChange(); + }, + children: widget.children[i].children, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } +} + +/// The [FTreeItemStyle] for a [FTreeItem]. +class FTreeItemStyle with Diagnosticable, _$FTreeItemStyleFunctions { + /// The background color. + /// + /// {@macro forui.foundation.doc_templates.WidgetStates.tappable} + @override + final FWidgetStateMap backgroundColor; + + /// The border radius. Defaults to `BorderRadius.circular(6)`. + @override + final BorderRadius borderRadius; + + /// The text style. + /// + /// {@macro forui.foundation.doc_templates.WidgetStates.tappable} + @override + final FWidgetStateMap textStyle; + + /// The icon style. + /// + /// {@macro forui.foundation.doc_templates.WidgetStates.tappable} + @override + final FWidgetStateMap iconStyle; + + /// The expand/collapse icon style. + /// + /// {@macro forui.foundation.doc_templates.WidgetStates.tappable} + @override + final FWidgetStateMap expandIconStyle; + + /// The padding around the item content. Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 4)`. + @override + final EdgeInsetsGeometry padding; + + /// The spacing between the icon and label. Defaults to 6. + @override + final double iconSpacing; + + /// The spacing between child items. Defaults to 2. + @override + final double childrenSpacing; + + /// The tappable style. + @override + final FTappableStyle tappableStyle; + + /// The focused outline style. + @override + final FFocusedOutlineStyle focusedOutlineStyle; + + /// The line style for tree connectors. + @override + final FTreeLineStyle lineStyle; + + /// The animation style for expand/collapse. + @override + final FTreeItemMotion motion; + + /// Creates a [FTreeItemStyle]. + const FTreeItemStyle({ + required this.backgroundColor, + required this.textStyle, + required this.iconStyle, + required this.expandIconStyle, + required this.tappableStyle, + required this.focusedOutlineStyle, + required this.lineStyle, + required this.motion, + this.borderRadius = const BorderRadius.all(Radius.circular(6)), + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + this.iconSpacing = 6, + this.childrenSpacing = 2, + }); + + /// Creates a [FTreeItemStyle] that inherits its properties. + FTreeItemStyle.inherit({required FColors colors, required FTypography typography, required FStyle style}) + : this( + backgroundColor: FWidgetStateMap({ + WidgetState.disabled: colors.disable(colors.background), + WidgetState.hovered: colors.secondary, + WidgetState.selected: colors.secondary, + WidgetState.any: Colors.transparent, + }), + textStyle: FWidgetStateMap({ + WidgetState.disabled: typography.base.copyWith(color: colors.disable(colors.foreground)), + WidgetState.any: typography.base.copyWith(color: colors.foreground), + }), + iconStyle: FWidgetStateMap({ + WidgetState.disabled: IconThemeData(color: colors.disable(colors.mutedForeground), size: 16), + WidgetState.any: IconThemeData(color: colors.mutedForeground, size: 16), + }), + expandIconStyle: FWidgetStateMap({ + WidgetState.disabled: IconThemeData(color: colors.disable(colors.mutedForeground), size: 16), + WidgetState.any: IconThemeData(color: colors.mutedForeground, size: 16), + }), + tappableStyle: FTappableStyle(), + focusedOutlineStyle: style.focusedOutlineStyle, + lineStyle: FTreeLineStyle.inherit(colors: colors, style: style), + motion: FTreeItemMotion(), + ); +} + +/// The animation style for [FTreeItem] expand/collapse. +class FTreeItemMotion with Diagnosticable { + /// The expand duration. Defaults to 250ms. + final Duration expandDuration; + + /// The collapse duration. Defaults to 200ms. + final Duration collapseDuration; + + /// The expand curve. Defaults to [Curves.easeInOut]. + final Curve expandCurve; + + /// The collapse curve. Defaults to [Curves.easeInOut]. + final Curve collapseCurve; + + /// The icon expand curve. Defaults to [Curves.easeInOut]. + final Curve iconExpandCurve; + + /// The icon collapse curve. Defaults to [Curves.easeInOut]. + final Curve iconCollapseCurve; + + /// The reveal tween. Defaults to `Tween(begin: 0.0, end: 1.0)`. + final Tween revealTween; + + /// The fade tween. Defaults to `Tween(begin: 0.0, end: 1.0)`. + final Tween fadeTween; + + /// The icon rotation tween. Defaults to `Tween(begin: 0.0, end: 0.25)`. + final Tween iconTween; + + /// Creates a [FTreeItemMotion]. + FTreeItemMotion({ + this.expandDuration = const Duration(milliseconds: 250), + this.collapseDuration = const Duration(milliseconds: 200), + this.expandCurve = Curves.easeInOut, + this.collapseCurve = Curves.easeInOut, + this.iconExpandCurve = Curves.easeInOut, + this.iconCollapseCurve = Curves.easeInOut, + Tween? revealTween, + Tween? fadeTween, + Tween? iconTween, + }) : revealTween = revealTween ?? Tween(begin: 0.0, end: 1.0), + fadeTween = fadeTween ?? Tween(begin: 0.0, end: 1.0), + iconTween = iconTween ?? Tween(begin: 0.0, end: 0.25); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('expandDuration', expandDuration)) + ..add(DiagnosticsProperty('collapseDuration', collapseDuration)) + ..add(DiagnosticsProperty('expandCurve', expandCurve)) + ..add(DiagnosticsProperty('collapseCurve', collapseCurve)) + ..add(DiagnosticsProperty('iconExpandCurve', iconExpandCurve)) + ..add(DiagnosticsProperty('iconCollapseCurve', iconCollapseCurve)) + ..add(DiagnosticsProperty('revealTween', revealTween)) + ..add(DiagnosticsProperty('fadeTween', fadeTween)) + ..add(DiagnosticsProperty('iconTween', iconTween)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FTreeItemMotion && + runtimeType == other.runtimeType && + expandDuration == other.expandDuration && + collapseDuration == other.collapseDuration && + expandCurve == other.expandCurve && + collapseCurve == other.collapseCurve && + iconExpandCurve == other.iconExpandCurve && + iconCollapseCurve == other.iconCollapseCurve && + revealTween == other.revealTween && + fadeTween == other.fadeTween && + iconTween == other.iconTween; + + @override + int get hashCode => + expandDuration.hashCode ^ + collapseDuration.hashCode ^ + expandCurve.hashCode ^ + collapseCurve.hashCode ^ + iconExpandCurve.hashCode ^ + iconCollapseCurve.hashCode ^ + revealTween.hashCode ^ + fadeTween.hashCode ^ + iconTween.hashCode; +} + +/// The line style for tree connectors. +/// +/// Note: The [dashPattern] is nullable, which affects equality comparisons. +class FTreeLineStyle with Diagnosticable, _$FTreeLineStyleFunctions { + /// The line color. + @override + final Color color; + + /// The line width. Defaults to 1.0. + @override + final double width; + + /// The line dash pattern. If null, the line is solid. + @override + final List? dashPattern; + + /// Creates a [FTreeLineStyle]. + const FTreeLineStyle({required this.color, this.width = 1.0, this.dashPattern}); + + /// Creates a [FTreeLineStyle] that inherits its properties. + FTreeLineStyle.inherit({required FColors colors, required FStyle style}) + : this(color: colors.border, width: style.borderWidth); + + @override + int get hashCode => Object.hash(color, width, Object.hashAll(dashPattern ?? [])); +} diff --git a/forui/lib/src/widgets/tree/tree_painter.dart b/forui/lib/src/widgets/tree/tree_painter.dart new file mode 100644 index 000000000..869c65d47 --- /dev/null +++ b/forui/lib/src/widgets/tree/tree_painter.dart @@ -0,0 +1,135 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; + +/// A custom painter that draws tree connection lines from a parent to its children. +/// +/// This painter draws: +/// - A vertical line from the top (parent item) to the last child +/// - Horizontal lines from the vertical line to each child +@internal +class FTreeChildrenLinePainter extends CustomPainter { + /// The line style. + final FTreeLineStyle lineStyle; + + /// The number of children. + final int childCount; + + /// The spacing between children. + final double childrenSpacing; + + /// The number of visible rows (including nested children) that each direct child occupies. + final List childRowCounts; + + /// The horizontal padding of each tree item. + final double itemPadding; + + /// The text direction for drawing lines. + final TextDirection textDirection; + + /// Creates a [FTreeChildrenLinePainter]. + const FTreeChildrenLinePainter({ + required this.lineStyle, + required this.childCount, + required this.childrenSpacing, + required this.childRowCounts, + required this.itemPadding, + required this.textDirection, + }); + + @override + void paint(Canvas canvas, Size size) { + if (childCount == 0) { + return; + } + + final paint = Paint() + ..color = lineStyle.color + ..strokeWidth = lineStyle.width + ..style = PaintingStyle.stroke; + + // For RTL, vertical line starts at the right edge; for LTR, at the left edge + final lineStartX = textDirection == TextDirection.rtl ? size.width : 0.0; + + // Calculate total number of rows and total spacing + final totalRows = childRowCounts.reduce((a, b) => a + b); + + // Calculate the height of a single row based on actual rendered size + final itemHeight = (size.height) / totalRows; + + // Calculate the vertical center position of each direct child + final childPositions = []; + var currentY = itemHeight / 2; // Center of first child (which is in the first row) + + for (var i = 0; i < childCount; i++) { + childPositions.add(currentY); + // Move to next child: advance by this child's row count, plus spacing + currentY += childRowCounts[i] * itemHeight; + } + + // Draw vertical line from top to last child's center + final lastChildY = childPositions.last; + if (lineStyle.dashPattern != null) { + _drawDashedLine(canvas, Offset(lineStartX, 0), Offset(lineStartX, lastChildY), paint, lineStyle.dashPattern!); + } else { + canvas.drawLine(Offset(lineStartX, 0), Offset(lineStartX, lastChildY), paint); + } + + // Draw horizontal lines to each child + // For RTL: line goes from right edge inward; for LTR: from left edge inward + final horizontalEndX = textDirection == TextDirection.rtl + ? size.width - (itemPadding - 2) // Start from right edge, go left + : itemPadding - 2; // Stop 2px before the icon area + + for (final childY in childPositions) { + if (lineStyle.dashPattern != null) { + _drawDashedLine( + canvas, + Offset(lineStartX, childY), + Offset(horizontalEndX, childY), + paint, + lineStyle.dashPattern!, + ); + } else { + canvas.drawLine(Offset(lineStartX, childY), Offset(horizontalEndX, childY), paint); + } + } + } + + void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint, List dashPattern) { + final path = Path() + ..moveTo(start.dx, start.dy) + ..lineTo(end.dx, end.dy); + + final metric = path.computeMetrics().first; + var distance = 0.0; + var drawDash = true; + var dashIndex = 0; + + while (distance < metric.length) { + final dashLength = dashPattern[dashIndex % dashPattern.length]; + if (drawDash) { + final extractPath = metric.extractPath( + distance, + distance + dashLength > metric.length ? metric.length : distance + dashLength, + ); + canvas.drawPath(extractPath, paint); + } + distance += dashLength; + drawDash = !drawDash; + dashIndex++; + } + } + + @override + bool shouldRepaint(FTreeChildrenLinePainter oldDelegate) => + lineStyle != oldDelegate.lineStyle || + childCount != oldDelegate.childCount || + childrenSpacing != oldDelegate.childrenSpacing || + childRowCounts != oldDelegate.childRowCounts || + itemPadding != oldDelegate.itemPadding || + textDirection != oldDelegate.textDirection; +} diff --git a/forui/lib/widgets/tree.dart b/forui/lib/widgets/tree.dart new file mode 100644 index 000000000..4d17344e0 --- /dev/null +++ b/forui/lib/widgets/tree.dart @@ -0,0 +1,10 @@ +/// {@category Widgets} +/// +/// A hierarchical tree widget for displaying nested data with visual connecting lines. +/// +/// See https://forui.dev/docs/data/tree for working examples. +library forui.widgets.tree; + +export '../src/widgets/tree/tree.dart'; +export '../src/widgets/tree/tree_controller.dart'; +export '../src/widgets/tree/tree_item.dart'; diff --git a/forui/test/golden/macos/tree/zinc-dark/default.png b/forui/test/golden/macos/tree/zinc-dark/default.png new file mode 100644 index 000000000..3f6fea68d Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/default.png differ diff --git a/forui/test/golden/macos/tree/zinc-dark/nested.png b/forui/test/golden/macos/tree/zinc-dark/nested.png new file mode 100644 index 000000000..963a59a14 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/nested.png differ diff --git a/forui/test/golden/macos/tree/zinc-dark/rtl-default.png b/forui/test/golden/macos/tree/zinc-dark/rtl-default.png new file mode 100644 index 000000000..83d772309 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/rtl-default.png differ diff --git a/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png b/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png new file mode 100644 index 000000000..3d3999b7e Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/rtl-nested.png differ diff --git a/forui/test/golden/macos/tree/zinc-dark/selected.png b/forui/test/golden/macos/tree/zinc-dark/selected.png new file mode 100644 index 000000000..a7d794bab Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-dark/selected.png differ diff --git a/forui/test/golden/macos/tree/zinc-light/default.png b/forui/test/golden/macos/tree/zinc-light/default.png new file mode 100644 index 000000000..a327df4a8 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/default.png differ diff --git a/forui/test/golden/macos/tree/zinc-light/nested.png b/forui/test/golden/macos/tree/zinc-light/nested.png new file mode 100644 index 000000000..a8ec757d3 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/nested.png differ diff --git a/forui/test/golden/macos/tree/zinc-light/rtl-default.png b/forui/test/golden/macos/tree/zinc-light/rtl-default.png new file mode 100644 index 000000000..130bf5eb8 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/rtl-default.png differ diff --git a/forui/test/golden/macos/tree/zinc-light/rtl-nested.png b/forui/test/golden/macos/tree/zinc-light/rtl-nested.png new file mode 100644 index 000000000..3c124cf38 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/rtl-nested.png differ diff --git a/forui/test/golden/macos/tree/zinc-light/selected.png b/forui/test/golden/macos/tree/zinc-light/selected.png new file mode 100644 index 000000000..d9af33084 Binary files /dev/null and b/forui/test/golden/macos/tree/zinc-light/selected.png differ diff --git a/forui/test/src/widgets/tree/tree_golden_test.dart b/forui/test/src/widgets/tree/tree_golden_test.dart new file mode 100644 index 000000000..82eee2de7 --- /dev/null +++ b/forui/test/src/widgets/tree/tree_golden_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; +import '../../test_scaffold.dart'; + +void main() { + group('blue screen', () { + testWidgets('FTree', (tester) async { + await tester.pumpWidget( + TestScaffold.blue( + child: FTree( + style: TestScaffold.blueScreen.treeStyle, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Folder 1'), + children: [FTreeItem(icon: const Icon(FIcons.file), label: const Text('File 1'), onPress: () {})], + ), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Folder 2'), onPress: () {}), + ], + ), + ), + ); + + await expectBlueScreen(); + }); + }); + + for (final theme in TestScaffold.themes) { + testWidgets('default - ${theme.name}', (tester) async { + await tester.pumpWidget( + TestScaffold( + theme: theme.data, + child: FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Apple'), + initiallyExpanded: true, + children: [ + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Red Apple'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Green Apple'), onPress: () {}), + ], + ), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Banana'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Cherry'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.file), label: const Text('Date'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Elderberry'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Fig'), onPress: () {}), + ], + ), + ), + ); + + await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/default.png')); + }); + + testWidgets('nested - ${theme.name}', (tester) async { + await tester.pumpWidget( + TestScaffold( + theme: theme.data, + child: FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 1'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 2'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 3'), + initiallyExpanded: true, + children: [ + FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {}), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ); + + await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/nested.png')); + }); + + testWidgets('selected - ${theme.name}', (tester) async { + await tester.pumpWidget( + TestScaffold( + theme: theme.data, + child: FTree( + children: [ + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Item 1'), selected: true, onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Item 2'), onPress: () {}), + ], + ), + ), + ); + + await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/selected.png')); + }); + + testWidgets('rtl-default - ${theme.name}', (tester) async { + await tester.pumpWidget( + TestScaffold( + theme: theme.data, + child: Directionality( + textDirection: TextDirection.rtl, + child: FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Apple'), + initiallyExpanded: true, + children: [ + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Red Apple'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Green Apple'), onPress: () {}), + ], + ), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Banana'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Cherry'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.file), label: const Text('Date'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Elderberry'), onPress: () {}), + FTreeItem(icon: const Icon(FIcons.folder), label: const Text('Fig'), onPress: () {}), + ], + ), + ), + ), + ); + + await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/rtl-default.png')); + }); + + testWidgets('rtl-nested - ${theme.name}', (tester) async { + await tester.pumpWidget( + TestScaffold( + theme: theme.data, + child: Directionality( + textDirection: TextDirection.rtl, + child: FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 1'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 2'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 3'), + initiallyExpanded: true, + children: [ + FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {}), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + + await expectLater(find.byType(TestScaffold), matchesGoldenFile('tree/${theme.name}/rtl-nested.png')); + }); + } +} diff --git a/forui/test/src/widgets/tree/tree_test.dart b/forui/test/src/widgets/tree/tree_test.dart new file mode 100644 index 000000000..8e577cdf2 --- /dev/null +++ b/forui/test/src/widgets/tree/tree_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; +import '../../test_scaffold.dart'; + +void main() { + group('FTree', () { + testWidgets('renders basic tree', (tester) async { + await tester.pumpWidget( + TestScaffold( + child: const FTree( + children: [ + FTreeItem(label: Text('Item 1')), + FTreeItem(label: Text('Item 2')), + ], + ), + ), + ); + + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + }); + + testWidgets('renders nested tree collapsed by default', (tester) async { + await tester.pumpWidget( + TestScaffold( + child: const FTree( + children: [ + FTreeItem( + label: Text('Parent'), + children: [ + FTreeItem(label: Text('Child 1')), + FTreeItem(label: Text('Child 2')), + ], + ), + ], + ), + ), + ); + + expect(find.text('Parent'), findsOneWidget); + // Note: Children exist in the widget tree but are hidden via FCollapsible + // They will be found but not visible + }); + + testWidgets('expands and collapses when pressed', (tester) async { + await tester.pumpWidget( + TestScaffold( + child: const FTree( + children: [ + FTreeItem( + label: Text('Parent'), + children: [FTreeItem(label: Text('Child'))], + ), + ], + ), + ), + ); + + // Tap to expand + await tester.tap(find.text('Parent')); + await tester.pumpAndSettle(); + + expect(find.text('Child'), findsOneWidget); + + // Tap to collapse + await tester.tap(find.text('Parent')); + await tester.pumpAndSettle(); + + // Child still exists but is collapsed + expect(find.text('Parent'), findsOneWidget); + }); + + testWidgets('initially expanded shows children', (tester) async { + await tester.pumpWidget( + TestScaffold( + child: const FTree( + children: [ + FTreeItem( + label: Text('Parent'), + initiallyExpanded: true, + children: [FTreeItem(label: Text('Child'))], + ), + ], + ), + ), + ); + + expect(find.text('Parent'), findsOneWidget); + expect(find.text('Child'), findsOneWidget); + }); + }); + + group('FTreeController', () { + test('expands item', () { + final controller = autoDispose(FTreeController()); + + expect(controller.isExpanded(0), false); + + controller.expand(0); + expect(controller.isExpanded(0), true); + }); + + test('collapses item', () { + final controller = autoDispose(FTreeController(expanded: {0})); + + expect(controller.isExpanded(0), true); + + controller.collapse(0); + expect(controller.isExpanded(0), false); + }); + + test('toggles item', () { + final controller = autoDispose(FTreeController()); + + expect(controller.isExpanded(0), false); + + controller.toggle(0); + expect(controller.isExpanded(0), true); + + controller.toggle(0); + expect(controller.isExpanded(0), false); + }); + }); +} diff --git a/samples/lib/main.dart b/samples/lib/main.dart index 73c60032e..e9c322c60 100644 --- a/samples/lib/main.dart +++ b/samples/lib/main.dart @@ -198,5 +198,6 @@ class _AppRouter extends RootStackRouter { AutoRoute(path: '/toast/behavior', page: BehaviorToastRoute.page), AutoRoute(path: '/toast/swipe', page: SwipeToastRoute.page), AutoRoute(path: '/tooltip/default', page: TooltipRoute.page), + AutoRoute(path: '/tree/default', page: TreeRoute.page), ]; } diff --git a/samples/lib/widgets/tree.dart b/samples/lib/widgets/tree.dart new file mode 100644 index 000000000..f18b63ac4 --- /dev/null +++ b/samples/lib/widgets/tree.dart @@ -0,0 +1,43 @@ +import 'package:flutter/widgets.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:forui/forui.dart'; + +import 'package:forui_samples/sample.dart'; + +@RoutePage() +class TreePage extends Sample { + final bool rtl; + + TreePage({@queryParam super.theme, @queryParam this.rtl = false}); + + @override + Widget sample(BuildContext context) { + final tree = FTree( + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 1'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 2'), + initiallyExpanded: true, + children: [ + FTreeItem( + icon: const Icon(FIcons.folder), + label: const Text('Level 3'), + initiallyExpanded: true, + children: [FTreeItem(icon: const Icon(FIcons.file), label: const Text('Deep File'), onPress: () {})], + ), + ], + ), + ], + ), + ], + ); + + return rtl ? Directionality(textDirection: TextDirection.rtl, child: tree) : tree; + } +}