import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; class SectionHeader extends StatelessWidget { final SectionKey sectionKey; final Widget? leading, trailing; final String title; final bool selectable; const SectionHeader({ super.key, required this.sectionKey, this.leading, required this.title, this.trailing, this.selectable = true, }); static const leadingSize = Size(48, 32); static const margin = EdgeInsets.symmetric(vertical: 0, horizontal: 8); static const padding = EdgeInsets.symmetric(vertical: 16, horizontal: 8); static const widgetSpanAlignment = PlaceholderAlignment.middle; @override Widget build(BuildContext context) { Widget child = _buildContent(context); if (settings.useTvLayout) { child = InkWell( onTap: _onTap(context), borderRadius: const BorderRadius.all(Radius.circular(123)), child: child, ); } return Container( alignment: AlignmentDirectional.centerStart, margin: margin, child: child, ); } Widget _buildContent(BuildContext context) { return Container( padding: padding, constraints: BoxConstraints(minHeight: leadingSize.height), child: GestureDetector( onTap: _onTap(context), onLongPress: selectable ? () { final selection = context.read>(); if (selection.isSelecting) { _toggleSectionSelection(context); } else { selection.select(); selection.addToSelection(_getSectionEntries(context)); } } : null, child: Text.rich( TextSpan( children: [ WidgetSpan( alignment: widgetSpanAlignment, child: _SectionSelectableLeading( selectable: selectable, sectionKey: sectionKey, browsingBuilder: leading != null ? (context) => Container( padding: const EdgeInsetsDirectional.only(end: 8, bottom: 4), width: leadingSize.width, height: leadingSize.height, child: leading, ) : null, onPressed: _onTap(context), ), ), TextSpan( text: title, style: AStyles.unknownTitleText, ), if (trailing != null) WidgetSpan( alignment: widgetSpanAlignment, child: Container( padding: const EdgeInsetsDirectional.only(start: 8, bottom: 2), child: trailing, ), ), ], ), ), ), ); } VoidCallback? _onTap(BuildContext context) => selectable ? () => _toggleSectionSelection(context) : null; List _getSectionEntries(BuildContext context) => context.read>().sections[sectionKey] ?? []; void _toggleSectionSelection(BuildContext context) { final sectionEntries = _getSectionEntries(context); final selection = context.read>(); final isSelected = selection.isSelected(sectionEntries); if (isSelected) { selection.removeFromSelection(sectionEntries); } else { selection.addToSelection(sectionEntries); } } // TODO TLAD [perf] cache header extent computation? static double getPreferredHeight({ required BuildContext context, required double maxWidth, required String title, bool hasLeading = false, bool hasTrailing = false, }) { final textScaleFactor = MediaQuery.textScaleFactorOf(context); final maxContentWidth = maxWidth - (SectionHeader.padding.horizontal + SectionHeader.margin.horizontal); final para = RenderParagraph( TextSpan( children: [ // as of Flutter v3.7.7, `RenderParagraph` fails to lay out `WidgetSpan` offscreen // so we use a hair space times a magic number to match width TextSpan( // 23 hair spaces match a width of 40.0 text: '\u200A' * (hasLeading ? 23 : 1), // force a higher first line to match leading icon/selector dimension style: TextStyle(height: 2.3 * textScaleFactor), ), if (hasTrailing) TextSpan(text: '\u200A' * 17), TextSpan( text: title, style: AStyles.unknownTitleText, ), ], ), textDirection: TextDirection.ltr, textScaleFactor: textScaleFactor, )..layout(BoxConstraints(maxWidth: maxContentWidth), parentUsesSize: true); return para.getMaxIntrinsicHeight(maxContentWidth); } } class _SectionSelectableLeading extends StatelessWidget { final bool selectable; final SectionKey sectionKey; final WidgetBuilder? browsingBuilder; final VoidCallback? onPressed; const _SectionSelectableLeading({ super.key, this.selectable = true, required this.sectionKey, required this.browsingBuilder, required this.onPressed, }); @override Widget build(BuildContext context) { if (!selectable) return _buildBrowsing(context); final isSelecting = context.select, bool>((selection) => selection.isSelecting); final Widget child = isSelecting ? _SectionSelectingLeading( sectionKey: sectionKey, onPressed: onPressed, ) : _buildBrowsing(context); return FocusTraversalGroup( descendantsAreFocusable: false, descendantsAreTraversable: false, child: AnimatedSwitcher( duration: Durations.sectionHeaderAnimation, switchInCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut, transitionBuilder: (child, animation) { Widget transition = ScaleTransition( scale: animation, child: child, ); if (browsingBuilder == null) { // when switching with a header that has no icon, // we also transition the size for a smooth push to the text transition = SizeTransition( axis: Axis.horizontal, sizeFactor: animation, child: transition, ); } return transition; }, child: child, ), ); } Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? SizedBox(height: SectionHeader.leadingSize.height); } class _SectionSelectingLeading extends StatelessWidget { final SectionKey sectionKey; final VoidCallback? onPressed; const _SectionSelectingLeading({ super.key, required this.sectionKey, required this.onPressed, }); @override Widget build(BuildContext context) { final sectionEntries = context.watch>().sections[sectionKey] ?? []; final selection = context.watch>(); final isSelected = selection.isSelected(sectionEntries); return AnimatedSwitcher( duration: Durations.sectionHeaderAnimation, switchInCurve: Curves.easeOutBack, switchOutCurve: Curves.easeOutBack, transitionBuilder: (child, animation) => ScaleTransition( scale: animation, child: child, ), child: TooltipTheme( key: ValueKey(isSelected), data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: IconButton( iconSize: 26, padding: const EdgeInsetsDirectional.only(end: 6, bottom: 4), onPressed: onPressed, tooltip: isSelected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip, constraints: BoxConstraints( minHeight: SectionHeader.leadingSize.height, minWidth: SectionHeader.leadingSize.width, ), icon: Icon(isSelected ? AIcons.selected : AIcons.unselected), ), ), ); } }