diff --git a/lib/common/widgets/flutter/vertical_tabs.dart b/lib/common/widgets/flutter/vertical_tabs.dart new file mode 100644 index 000000000..8d0c6f860 --- /dev/null +++ b/lib/common/widgets/flutter/vertical_tabs.dart @@ -0,0 +1,2399 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole, lerpDouble; + +import 'package:PiliPlus/pages/main/controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:get/get_core/src/get_main.dart'; +import 'package:get/get_instance/src/extension_instance.dart'; + +const double _kTabWidth = 51.0; +const double _kTextAndIconTabWidth = 72.0; +const EdgeInsets _kTabLabelPadding = EdgeInsets.symmetric( + vertical: 7.0, + horizontal: 5.0, +); + +/// A Material Design [VerticalTabBar] tab. +/// +/// If both [icon] and [text] are provided, the text is displayed below +/// the icon. +/// +/// See also: +/// +/// * [VerticalTabBar], which displays a row of tabs. +/// * [TabBarView], which displays a widget for the currently selected tab. +/// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView]. +/// * +class VerticalTab extends StatelessWidget { + /// Creates a Material Design [VerticalTabBar] tab. + /// + /// At least one of [text], [icon], and [child] must be non-null. The [text] + /// and [child] arguments must not be used at the same time. The + /// [iconMargin] is only useful when [icon] and either one of [text] or + /// [child] is non-null. + const VerticalTab({ + super.key, + this.text, + this.icon, + this.iconMargin, + this.width, + this.child, + }) : assert(text != null || child != null || icon != null), + assert(text == null || child == null); + + /// The text to display as the tab's label. + /// + /// Must not be used in combination with [child]. + final String? text; + + /// The widget to be used as the tab's label. + /// + /// Usually a [Text] widget, possibly wrapped in a [Semantics] widget. + /// + /// Must not be used in combination with [text]. + final Widget? child; + + /// An icon to display as the tab's label. + final Widget? icon; + + /// The margin added around the tab's icon. + /// + /// Only useful when used in combination with [icon], and either one of + /// [text] or [child] is non-null. + /// + /// Defaults to 2 pixels of bottom margin. If [ThemeData.useMaterial3] is false, + /// then defaults to 10 pixels of bottom margin. + final EdgeInsetsGeometry? iconMargin; + + /// The height of the [VerticalTab]. + /// + /// If null, the height will be calculated based on the content of the [VerticalTab]. When `icon` is not + /// null along with `child` or `text`, the default height is 72.0 pixels. Without an `icon`, the + /// height is 46.0 pixels. + /// + /// {@tool snippet} + /// + /// The provided tab height cannot be lower than the default height. Use + /// [PreferredSize] widget to adjust the overall [VerticalTabBar] height and match + /// the provided tab [height]: + /// + /// ```dart + /// bottom: const PreferredSize( + /// preferredSize: Size.fromHeight(20.0), + /// child: TabBar( + /// tabs: [ + /// Tab( + /// text: 'Tab 1', + /// height: 20.0, + /// ), + /// Tab( + /// text: 'Tab 2', + /// height: 20.0, + /// ), + /// ], + /// ), + /// ), + /// ``` + /// {@end-tool} + final double? width; + + Widget _buildLabelText() { + return child ?? Text(text!, style: const TextStyle(fontSize: 15)); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + + final double calculatedWidth; + final Widget label; + if (icon == null) { + calculatedWidth = _kTabWidth; + label = _buildLabelText(); + } else if (text == null && child == null) { + calculatedWidth = _kTabWidth; + label = icon!; + } else { + calculatedWidth = _kTextAndIconTabWidth; + final EdgeInsetsGeometry effectiveIconMargin = + iconMargin ?? + (Theme.of(context).useMaterial3 + ? _TabsPrimaryDefaultsM3.iconMargin + : _TabsDefaultsM2.iconMargin); + // dom + label = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding(padding: effectiveIconMargin, child: icon), + Flexible(child: _buildLabelText()), + ], + ); + } + + return SizedBox( + width: width ?? calculatedWidth, // dom + child: Center(heightFactor: 1.0, child: label), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('text', text, defaultValue: null)); + } +} + +class _TabStyle extends AnimatedWidget { + const _TabStyle({ + required Animation animation, + required this.isSelected, + required this.isPrimary, + required this.labelColor, + required this.unselectedLabelColor, + required this.labelStyle, + required this.unselectedLabelStyle, + required this.defaults, + required this.child, + }) : super(listenable: animation); + + final TextStyle? labelStyle; + final TextStyle? unselectedLabelStyle; + final bool isSelected; + final bool isPrimary; + final Color? labelColor; + final Color? unselectedLabelColor; + final TabBarThemeData defaults; + final Widget child; + + WidgetStateColor _resolveWithLabelColor( + BuildContext context, { + IconThemeData? iconTheme, + }) { + final ThemeData themeData = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final Animation animation = listenable as Animation; + + // labelStyle.color (and tabBarTheme.labelStyle.color) is not considered + // as it'll be a breaking change without a possible migration plan. for + // details: https://github.com/flutter/flutter/pull/109541#issuecomment-1294241417 + Color selectedColor = + labelColor ?? + tabBarTheme.labelColor ?? + labelStyle?.color ?? + tabBarTheme.labelStyle?.color ?? + defaults.labelColor!; + + final Color unselectedColor; + + if (selectedColor is WidgetStateColor) { + unselectedColor = selectedColor.resolve(const {}); + selectedColor = selectedColor.resolve(const { + WidgetState.selected, + }); + } else { + // unselectedLabelColor and tabBarTheme.unselectedLabelColor are ignored + // when labelColor is a WidgetStateColor. + unselectedColor = + unselectedLabelColor ?? + tabBarTheme.unselectedLabelColor ?? + unselectedLabelStyle?.color ?? + tabBarTheme.unselectedLabelStyle?.color ?? + iconTheme?.color ?? + (themeData.useMaterial3 + ? defaults.unselectedLabelColor! + : selectedColor.withAlpha(0xB2)); // 70% alpha + } + + return WidgetStateColor.resolveWith((Set states) { + if (states.contains(WidgetState.selected)) { + return Color.lerp(selectedColor, unselectedColor, animation.value)!; + } + return Color.lerp(unselectedColor, selectedColor, animation.value)!; + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final Animation animation = listenable as Animation; + + final Set states = isSelected + ? const {WidgetState.selected} + : const {}; + + // To enable TextStyle.lerp(style1, style2, value), both styles must have + // the same value of inherit. Force that to be inherit=true here. + final TextStyle selectedStyle = defaults.labelStyle! + .merge(labelStyle ?? tabBarTheme.labelStyle) + .copyWith(inherit: true); + final TextStyle unselectedStyle = defaults.unselectedLabelStyle! + .merge( + unselectedLabelStyle ?? + tabBarTheme.unselectedLabelStyle ?? + labelStyle, + ) + .copyWith(inherit: true); + final TextStyle textStyle = isSelected + ? TextStyle.lerp(selectedStyle, unselectedStyle, animation.value)! + : TextStyle.lerp(unselectedStyle, selectedStyle, animation.value)!; + final Color defaultIconColor = switch (theme.colorScheme.brightness) { + Brightness.light => kDefaultIconDarkColor, + Brightness.dark => kDefaultIconLightColor, + }; + final IconThemeData? customIconTheme = switch (IconTheme.of(context)) { + final IconThemeData iconTheme when iconTheme.color != defaultIconColor => + iconTheme, + _ => null, + }; + final Color iconColor = _resolveWithLabelColor( + context, + iconTheme: customIconTheme, + ).resolve(states); + final Color labelColor = _resolveWithLabelColor(context).resolve(states); + + return DefaultTextStyle( + style: textStyle.copyWith(color: labelColor), + child: IconTheme.merge( + data: IconThemeData( + size: customIconTheme?.size ?? 24.0, + color: iconColor, + ), + child: child, + ), + ); + } +} + +typedef _LayoutCallback = + void Function( + List yOffsets, + TextDirection textDirection, + double width, + ); + +class _TabLabelBarRenderer extends RenderFlex { + _TabLabelBarRenderer({ + required super.direction, + required super.mainAxisSize, + required super.mainAxisAlignment, + required super.crossAxisAlignment, + required TextDirection super.textDirection, + required super.verticalDirection, + required this.onPerformLayout, + }); + + _LayoutCallback onPerformLayout; + + @override + void performLayout() { + super.performLayout(); + // yOffsets will contain childCount+1 values, giving the offsets of the + // leading edge of the first tab as the first value, of the leading edge of + // the each subsequent tab as each subsequent value, and of the trailing + // edge of the last tab as the last value. + RenderBox? child = firstChild; + final List yOffsets = []; + while (child != null) { + final FlexParentData childParentData = + child.parentData! as FlexParentData; + yOffsets.add(childParentData.offset.dy); // dom + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + yOffsets.add(size.height); + onPerformLayout(yOffsets, .ltr, size.height); + } +} + +// This class and its renderer class only exist to report the widths of the tabs +// upon layout. The tab widths are only used at paint time (see _IndicatorPainter) +// or in response to input. +class _TabLabelBar extends Flex { + const _TabLabelBar({ + super.children, + required this.onPerformLayout, + required super.mainAxisSize, + }) : super( + direction: Axis.vertical, // dom + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, // dom + verticalDirection: VerticalDirection.down, + ); + + final _LayoutCallback onPerformLayout; + + @override + RenderFlex createRenderObject(BuildContext context) { + return _TabLabelBarRenderer( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: .ltr, // getEffectiveTextDirection(context)!, + verticalDirection: verticalDirection, + onPerformLayout: onPerformLayout, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _TabLabelBarRenderer renderObject, + ) { + super.updateRenderObject(context, renderObject); + renderObject.onPerformLayout = onPerformLayout; + } +} + +double _indexChangeProgress(TabController controller) { + final double controllerValue = controller.animation!.value; + final double previousIndex = controller.previousIndex.toDouble(); + final double currentIndex = controller.index.toDouble(); + + // The controller's offset is changing because the user is dragging the + // TabBarView's PageView to the left or right. + if (!controller.indexIsChanging) { + return clampDouble((currentIndex - controllerValue).abs(), 0.0, 1.0); + } + + // The TabController animation's value is changing from previousIndex to currentIndex. + return (controllerValue - currentIndex).abs() / + (currentIndex - previousIndex).abs(); +} + +class _DividerPainter extends CustomPainter { + _DividerPainter({required this.dividerColor, required this.dividerWidth}); + + final Color dividerColor; + final double dividerWidth; + + @override + void paint(Canvas canvas, Size size) { + if (dividerWidth <= 0.0) { + return; + } + + final Paint paint = Paint() + ..color = dividerColor + ..strokeWidth = dividerWidth; + + // dom + final dx = size.width - (paint.strokeWidth / 2); + canvas.drawLine(Offset(dx, 0), Offset(dx, size.height), paint); + } + + @override + bool shouldRepaint(_DividerPainter oldDelegate) { + return oldDelegate.dividerColor != dividerColor || + oldDelegate.dividerWidth != dividerWidth; + } +} + +class _IndicatorPainter extends CustomPainter { + _IndicatorPainter({ + required this.controller, + required this.indicator, + required this.indicatorSize, + required this.tabKeys, + required _IndicatorPainter? old, + required this.indicatorPadding, + required this.labelPadding, + this.dividerColor, + this.dividerWidth, + required this.showDivider, + this.devicePixelRatio, + required this.indicatorAnimation, + required this.textDirection, + }) : super(repaint: controller.animation) { + assert(debugMaybeDispatchCreated('material', '_IndicatorPainter', this)); + if (old != null) { + saveTabOffsets(old._currentTabOffsets, old._currentTextDirection); + } + } + + final TabController controller; + final Decoration indicator; + final TabBarIndicatorSize indicatorSize; + final EdgeInsetsGeometry indicatorPadding; + final List tabKeys; + final EdgeInsetsGeometry labelPadding; + final Color? dividerColor; + final double? dividerWidth; + final bool showDivider; + final double? devicePixelRatio; + final TabIndicatorAnimation indicatorAnimation; + final TextDirection textDirection; + + // _currentTabOffsets and _currentTextDirection are set each time TabBar + // layout is completed. These values can be null when TabBar contains no + // tabs, since there are nothing to lay out. + List? _currentTabOffsets; + TextDirection? _currentTextDirection; + + Rect? _currentRect; + BoxPainter? _painter; + bool _needsPaint = false; + void markNeedsPaint() { + _needsPaint = true; + } + + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + _painter?.dispose(); + } + + void saveTabOffsets(List? tabOffsets, TextDirection? textDirection) { + _currentTabOffsets = tabOffsets; + _currentTextDirection = textDirection; + } + + // _currentTabOffsets[index] is the offset of the start edge of the tab at index, and + // _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab. + int get maxTabIndex => _currentTabOffsets!.length - 2; + + double centerOf(int tabIndex) { + assert(_currentTabOffsets != null); + assert(_currentTabOffsets!.isNotEmpty); + assert(tabIndex >= 0); + assert(tabIndex <= maxTabIndex); + return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) / + 2.0; + } + + Rect indicatorRect(Size tabBarSize, int tabIndex) { + assert(_currentTabOffsets != null); + assert(_currentTextDirection != null); + assert(_currentTabOffsets!.isNotEmpty); + assert(tabIndex >= 0); + assert(tabIndex <= maxTabIndex); + double tabLeft, tabRight; + (tabLeft, tabRight) = switch (_currentTextDirection!) { + TextDirection.rtl => ( + _currentTabOffsets![tabIndex + 1], + _currentTabOffsets![tabIndex], + ), + TextDirection.ltr => ( + _currentTabOffsets![tabIndex], + _currentTabOffsets![tabIndex + 1], + ), + }; + + if (indicatorSize == TabBarIndicatorSize.label) { + final double tabWidth = + tabKeys[tabIndex].currentContext!.size!.height; // dom + final EdgeInsets insets = labelPadding.resolve(_currentTextDirection); + final double delta = + ((tabRight - tabLeft) - (tabWidth + insets.vertical)) / 2.0; // dom + tabLeft += delta + insets.top; // dom + tabRight = tabLeft + tabWidth; + } + + final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection); + // dom + final Rect rect = Rect.fromLTWH( + 0, + tabLeft, + tabBarSize.width, + tabRight - tabLeft, + ); + + if (!(rect.size >= insets.collapsedSize)) { + throw FlutterError( + 'indicatorPadding insets should be less than Tab Size\n' + 'Rect Size : ${rect.size}, Insets: $insets', + ); + } + return insets.deflateRect(rect); + } + + @override + void paint(Canvas canvas, Size size) { + _needsPaint = false; + _painter ??= indicator.createBoxPainter(markNeedsPaint); + + final double value = controller.animation!.value; + + _currentRect = switch (indicatorAnimation) { + TabIndicatorAnimation.linear => _applyLinearEffect( + size: size, + value: value, + ), + TabIndicatorAnimation.elastic => _applyElasticEffect( + size: size, + value: value, + ), + }; + + assert(_currentRect != null); + + final ImageConfiguration configuration = ImageConfiguration( + size: _currentRect!.size, + textDirection: _currentTextDirection, + devicePixelRatio: devicePixelRatio, + ); + if (showDivider && dividerWidth! > 0) { + final Paint dividerPaint = Paint() + ..color = dividerColor! + ..strokeWidth = dividerWidth!; + final dx = size.width - (dividerPaint.strokeWidth / 2); + final Offset dividerP1 = Offset(dx, 0); + final Offset dividerP2 = Offset(dx, size.height); + // dom + canvas.drawLine(dividerP1, dividerP2, dividerPaint); + } + _painter!.paint(canvas, _currentRect!.topLeft, configuration); + } + + /// Applies the linear effect to the indicator. + Rect? _applyLinearEffect({required Size size, required double value}) { + final double index = controller.index.toDouble(); + final bool ltr = index > value; + final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex); + final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex); + final Rect fromRect = indicatorRect(size, from); + final Rect toRect = indicatorRect(size, to); + return Rect.lerp(fromRect, toRect, (value - from).abs()); + } + + // Ease out sine (decelerating). + double decelerateInterpolation(double fraction) { + return math.sin((fraction * math.pi) / 2.0); + } + + // Ease in sine (accelerating). + double accelerateInterpolation(double fraction) { + return 1.0 - math.cos((fraction * math.pi) / 2.0); + } + + /// Applies the elastic effect to the indicator. + // dom + Rect? _applyElasticEffect({required Size size, required double value}) { + final double index = controller.index.toDouble(); + double progressLeft = (index - value).abs(); + + final int to = progressLeft == 0.0 || !controller.indexIsChanging + ? switch (textDirection) { + TextDirection.ltr => value.ceil(), + TextDirection.rtl => value.floor(), + }.clamp(0, maxTabIndex) + : controller.index; + final int from = progressLeft == 0.0 || !controller.indexIsChanging + ? switch (textDirection) { + TextDirection.ltr => (to - 1), + TextDirection.rtl => (to + 1), + }.clamp(0, maxTabIndex) + : controller.previousIndex; + final Rect toRect = indicatorRect(size, to); + final Rect fromRect = indicatorRect(size, from); + final Rect rect = Rect.lerp(fromRect, toRect, (value - from).abs())!; + + // If the tab animation is completed, there is no need to stretch the indicator + // This only works for the tab change animation via tab index, not when + // dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations. + if (controller.animation!.isCompleted) { + return rect; + } + + final double tabChangeProgress; + + if (controller.indexIsChanging) { + final int tabsDelta = (controller.index - controller.previousIndex).abs(); + if (tabsDelta != 0) { + progressLeft /= tabsDelta; + } + tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0); + } else { + tabChangeProgress = (index - value).abs(); + } + + // If the animation has finished, there is no need to apply the stretch effect. + if (tabChangeProgress == 1.0) { + return rect; + } + + final double topFraction; + final double bottomFraction; + final bool isMovingRight = switch (textDirection) { + TextDirection.ltr => + controller.indexIsChanging ? index > value : value > index, + TextDirection.rtl => + controller.indexIsChanging ? value > index : index > value, + }; + if (isMovingRight) { + topFraction = accelerateInterpolation(tabChangeProgress); + bottomFraction = decelerateInterpolation(tabChangeProgress); + } else { + topFraction = decelerateInterpolation(tabChangeProgress); + bottomFraction = accelerateInterpolation(tabChangeProgress); + } + + final double lerpRectTop; + final double lerpRectBottom; + + // The controller.indexIsChanging is true when the Tab is pressed, instead of swipe to change tabs. + // If the tab is pressed then only lerp between fromRect and toRect. + if (controller.indexIsChanging) { + lerpRectTop = lerpDouble(fromRect.top, toRect.top, topFraction)!; + lerpRectBottom = lerpDouble( + fromRect.bottom, + toRect.bottom, + bottomFraction, + )!; + } else { + // Switch the Rect left and right lerp order based on swipe direction. + lerpRectTop = switch (isMovingRight) { + true => lerpDouble(fromRect.top, toRect.top, topFraction)!, + false => lerpDouble(toRect.top, fromRect.top, topFraction)!, + }; + lerpRectBottom = switch (isMovingRight) { + true => lerpDouble(fromRect.bottom, toRect.bottom, bottomFraction)!, + false => lerpDouble(toRect.bottom, fromRect.bottom, bottomFraction)!, + }; + } + + return Rect.fromLTRB(rect.left, lerpRectTop, rect.right, lerpRectBottom); + } + + @override + bool shouldRepaint(_IndicatorPainter old) { + return _needsPaint || + controller != old.controller || + indicator != old.indicator || + tabKeys.length != old.tabKeys.length || + (!listEquals(_currentTabOffsets, old._currentTabOffsets)) || + _currentTextDirection != old._currentTextDirection; + } +} + +class _ChangeAnimation extends Animation + with AnimationWithParentMixin { + _ChangeAnimation(this.controller); + + final TabController controller; + + @override + Animation get parent => controller.animation!; + + @override + void removeStatusListener(AnimationStatusListener listener) { + if (controller.animation != null) { + super.removeStatusListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + if (controller.animation != null) { + super.removeListener(listener); + } + } + + @override + double get value => _indexChangeProgress(controller); +} + +class _DragAnimation extends Animation + with AnimationWithParentMixin { + _DragAnimation(this.controller, this.index); + + final TabController controller; + final int index; + + @override + Animation get parent => controller.animation!; + + @override + void removeStatusListener(AnimationStatusListener listener) { + if (controller.animation != null) { + super.removeStatusListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + if (controller.animation != null) { + super.removeListener(listener); + } + } + + @override + double get value { + assert(!controller.indexIsChanging); + final double controllerMaxValue = (controller.length - 1).toDouble(); + final double controllerValue = clampDouble( + controller.animation!.value, + 0.0, + controllerMaxValue, + ); + return clampDouble((controllerValue - index.toDouble()).abs(), 0.0, 1.0); + } +} + +// This class, and TabBarScrollController, only exist to handle the case +// where a scrollable TabBar has a non-zero initialIndex. In that case we can +// only compute the scroll position's initial scroll offset (the "correct" +// pixels value) after the TabBar viewport width and scroll limits are known. +class _TabBarScrollPosition extends ScrollPositionWithSingleContext { + _TabBarScrollPosition({ + required super.physics, + required super.context, + required super.oldPosition, + required this.tabBar, + }) : super(initialPixels: null); + + final _VerticalTabBarState tabBar; + + bool _viewportDimensionWasNonZero = false; + + // The scroll position should be adjusted at least once. + bool _needsPixelsCorrection = true; + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + bool result = true; + if (!_viewportDimensionWasNonZero) { + _viewportDimensionWasNonZero = viewportDimension != 0.0; + } + // If the viewport never had a non-zero dimension, we just want to jump + // to the initial scroll position to avoid strange scrolling effects in + // release mode: the viewport temporarily may have a dimension of zero + // before the actual dimension is calculated. In that scenario, setting + // the actual dimension would cause a strange scroll effect without this + // guard because the super call below would start a ballistic scroll activity. + if (!_viewportDimensionWasNonZero || _needsPixelsCorrection) { + _needsPixelsCorrection = false; + correctPixels( + tabBar._initialScrollOffset( + viewportDimension, + minScrollExtent, + maxScrollExtent, + ), + ); + result = false; + } + return super.applyContentDimensions(minScrollExtent, maxScrollExtent) && + result; + } + + void markNeedsPixelsCorrection() { + _needsPixelsCorrection = true; + } +} + +// This class, and TabBarScrollPosition, only exist to handle the case +// where a scrollable TabBar has a non-zero initialIndex. +class _TabBarScrollController extends ScrollController { + _TabBarScrollController(this.tabBar); + + final _VerticalTabBarState tabBar; + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + return _TabBarScrollPosition( + physics: physics, + context: context, + oldPosition: oldPosition, + tabBar: tabBar, + ); + } +} + +/// A Material Design primary tab bar. +/// +/// Primary tabs are placed at the top of the content pane under a top app bar. +/// They display the main content destinations. +/// +/// Typically created as the [AppBar.bottom] part of an [AppBar] and in +/// conjunction with a [TabBarView]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40} +/// +/// If a [TabController] is not provided, then a [DefaultTabController] ancestor +/// must be provided instead. The tab controller's [TabController.length] must +/// equal the length of the [tabs] list and the length of the +/// [TabBarView.children] list. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Uses values from [TabBarThemeData] if it is set in the current context. +/// +/// {@tool dartpad} +/// This sample shows the implementation of [VerticalTabBar] and [TabBarView] using a [DefaultTabController]. +/// Each [VerticalTab] corresponds to a child of the [TabBarView] in the order they are written. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// [VerticalTabBar] can also be implemented by using a [TabController] which provides more options +/// to control the behavior of the [VerticalTabBar] and [TabBarView]. This can be used instead of +/// a [DefaultTabController], demonstrated below. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases nested Material 3 [VerticalTabBar]s. It consists of a primary +/// [VerticalTabBar] with nested a secondary [VerticalTabBar]. The primary [VerticalTabBar] uses a +/// [DefaultTabController] while the secondary [VerticalTabBar] uses a [TabController]. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TabBar.secondary], for a secondary tab bar. +/// * [TabBarView], which displays page views that correspond to each tab. +/// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView]. +/// * https://m3.material.io/components/tabs/overview, the Material 3 +/// tab bar specification. +class VerticalTabBar extends StatefulWidget { + /// Creates a Material Design primary tab bar. + /// + /// The length of the [tabs] argument must match the [controller]'s + /// [TabController.length]. + /// + /// If a [TabController] is not provided, then there must be a + /// [DefaultTabController] ancestor. + /// + /// The [indicatorWeight] parameter defaults to 2. + /// + /// The [indicatorPadding] parameter defaults to [EdgeInsets.zero]. + /// + /// If [indicator] is not null or provided from [TabBarTheme], + /// then [indicatorWeight] and [indicatorColor] are ignored. + const VerticalTabBar({ + super.key, + required this.tabs, + this.controller, + this.isScrollable = false, + this.padding, + this.indicatorColor, + this.automaticIndicatorColorAdjustment = true, + this.indicatorWeight = 2.0, + this.indicatorPadding = EdgeInsets.zero, + this.indicator, + this.indicatorSize, + this.dividerColor, + this.dividerWidth, + this.labelColor, + this.labelStyle, + this.labelPadding, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.overlayColor, + this.mouseCursor, + this.enableFeedback, + this.onTap, + this.onHover, + this.onFocusChange, + this.physics, + this.splashFactory, + this.splashBorderRadius, + this.tabAlignment, + this.textScaler, + this.indicatorAnimation, + }) : _isPrimary = true, + assert(indicator != null || (indicatorWeight > 0.0)); + + /// Creates a Material Design secondary tab bar. + /// + /// Secondary tabs are used within a content area to further separate related + /// content and establish hierarchy. + /// + /// {@tool dartpad} + /// This sample showcases nested Material 3 [VerticalTabBar]s. It consists of a primary + /// [VerticalTabBar] with nested a secondary [VerticalTabBar]. The primary [VerticalTabBar] uses a + /// [DefaultTabController] while the secondary [VerticalTabBar] uses a [TabController]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [VerticalTabBar], for a primary tab bar. + /// * [TabBarView], which displays page views that correspond to each tab. + /// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView]. + /// * https://m3.material.io/components/tabs/overview, the Material 3 + /// tab bar specification. + const VerticalTabBar.secondary({ + super.key, + required this.tabs, + this.controller, + this.isScrollable = false, + this.padding, + this.indicatorColor, + this.automaticIndicatorColorAdjustment = true, + this.indicatorWeight = 2.0, + this.indicatorPadding = EdgeInsets.zero, + this.indicator, + this.indicatorSize, + this.dividerColor, + this.dividerWidth, + this.labelColor, + this.labelStyle, + this.labelPadding, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.overlayColor, + this.mouseCursor, + this.enableFeedback, + this.onTap, + this.onHover, + this.onFocusChange, + this.physics, + this.splashFactory, + this.splashBorderRadius, + this.tabAlignment, + this.textScaler, + this.indicatorAnimation, + }) : _isPrimary = false, + assert(indicator != null || (indicatorWeight > 0.0)); + + /// Typically a list of two or more [VerticalTab] widgets. + /// + /// The length of this list must match the [controller]'s [TabController.length] + /// and the length of the [TabBarView.children] list. + final List tabs; + + /// This widget's selection and animation state. + /// + /// If [TabController] is not provided, then the value of [DefaultTabController.of] + /// will be used. + final TabController? controller; + + /// Whether this tab bar can be scrolled horizontally. + /// + /// If [isScrollable] is true, then each tab is as wide as needed for its label + /// and the entire [VerticalTabBar] is scrollable. Otherwise each tab gets an equal + /// share of the available space. + final bool isScrollable; + + /// The amount of space by which to inset the tab bar. + /// + /// When [isScrollable] is false, this will yield the same result as if [VerticalTabBar] was wrapped + /// in a [Padding] widget. When [isScrollable] is true, the scrollable itself is inset, + /// allowing the padding to scroll with the tab bar, rather than enclosing it. + final EdgeInsetsGeometry? padding; + + /// The color of the line that appears below the selected tab. + /// + /// If this parameter is null, then the value of the Theme's indicatorColor + /// property is used. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// this property is ignored. + final Color? indicatorColor; + + /// The thickness of the line that appears below the selected tab. + /// + /// The value of this parameter must be greater than zero. + /// + /// If [ThemeData.useMaterial3] is true and [VerticalTabBar] is used to create a + /// primary tab bar, the default value is 3.0. If the provided value is less + /// than 3.0, the default value is used. + /// + /// If [ThemeData.useMaterial3] is true and [TabBar.secondary] is used to + /// create a secondary tab bar, the default value is 2.0. + /// + /// If [ThemeData.useMaterial3] is false, the default value is 2.0. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// this property is ignored. + final double indicatorWeight; + + /// The padding for the indicator. + /// + /// The default value of this property is [EdgeInsets.zero]. + /// + /// For [isScrollable] tab bars, specifying [kTabLabelPadding] will align + /// the indicator with the tab's text for [VerticalTab] widgets and all but the + /// shortest [VerticalTab.text] values. + final EdgeInsetsGeometry indicatorPadding; + + /// Defines the appearance of the selected tab indicator. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// the [indicatorColor] and [indicatorWeight] properties are ignored. + /// + /// The default, underline-style, selected tab indicator can be defined with + /// [UnderlineTabIndicator]. + /// + /// The indicator's size is based on the tab's bounds. If [indicatorSize] + /// is [TabBarIndicatorSize.tab] the tab's bounds are as wide as the space + /// occupied by the tab in the tab bar. If [indicatorSize] is + /// [TabBarIndicatorSize.label], then the tab's bounds are only as wide as + /// the tab widget itself. + /// + /// See also: + /// + /// * [splashBorderRadius], which defines the clipping radius of the splash + /// and is generally used with [BoxDecoration.borderRadius]. + final Decoration? indicator; + + /// Whether this tab bar should automatically adjust the [indicatorColor]. + /// + /// The default value of this property is true. + /// + /// If [automaticIndicatorColorAdjustment] is true, + /// then the [indicatorColor] will be automatically adjusted to [Colors.white] + /// when the [indicatorColor] is same as [Material.color] of the [Material] + /// parent widget. + final bool automaticIndicatorColorAdjustment; + + /// Defines how the selected tab indicator's size is computed. + /// + /// The size of the selected tab indicator is defined relative to the + /// tab's overall bounds if [indicatorSize] is [TabBarIndicatorSize.tab] + /// (the default) or relative to the bounds of the tab's widget if + /// [indicatorSize] is [TabBarIndicatorSize.label]. + /// + /// The selected tab's location appearance can be refined further with + /// the [indicatorColor], [indicatorWeight], [indicatorPadding], and + /// [indicator] properties. + final TabBarIndicatorSize? indicatorSize; + + /// The color of the divider. + /// + /// If the [dividerColor] is [Colors.transparent], then the divider will not be drawn. + /// + /// If null and [ThemeData.useMaterial3] is false, [TabBarThemeData.dividerColor] + /// color is used. If that is null and [ThemeData.useMaterial3] is true, + /// [ColorScheme.outlineVariant] will be used, otherwise divider will not be drawn. + final Color? dividerColor; + + /// The height of the divider. + /// + /// If the [dividerWidth] is zero or negative, then the divider will not be drawn. + /// + /// If null and [ThemeData.useMaterial3] is true, [TabBarThemeData.dividerHeight] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, 1.0 will be used. + /// Otherwise divider will not be drawn. + final double? dividerWidth; + + /// The color of selected tab labels. + /// + /// If null, then [TabBarThemeData.labelColor] is used. If that is also null and + /// [ThemeData.useMaterial3] is true, [ColorScheme.primary] will be used, + /// otherwise the color of the [ThemeData.primaryTextTheme]'s + /// [TextTheme.bodyLarge] text color is used. + /// + /// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a + /// [WidgetStateColor], then the effective tab color will depend on the + /// [WidgetState.selected] state, i.e. if the [VerticalTab] is selected or not, + /// ignoring [unselectedLabelColor] even if it's non-null. + /// + /// When this color or the [TabBarThemeData.labelColor] is specified, it overrides + /// the [TextStyle.color] specified for the [labelStyle] or the + /// [TabBarThemeData.labelStyle]. + /// + /// See also: + /// + /// * [unselectedLabelColor], for color of unselected tab labels. + final Color? labelColor; + + /// The color of unselected tab labels. + /// + /// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a + /// [WidgetStateColor], then the unselected tabs are rendered with + /// that [WidgetStateColor]'s resolved color for unselected state, even if + /// [unselectedLabelColor] is non-null. + /// + /// If null, then [TabBarThemeData.unselectedLabelColor] is used. If that is also + /// null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant] + /// will be used, otherwise unselected tab labels are rendered with + /// [labelColor] at 70% opacity. + /// + /// When this color or the [TabBarThemeData.unselectedLabelColor] is specified, it + /// overrides the [TextStyle.color] specified for the [unselectedLabelStyle] + /// or the [TabBarThemeData.unselectedLabelStyle]. + /// + /// See also: + /// + /// * [labelColor], for color of selected tab labels. + final Color? unselectedLabelColor; + + /// The text style of the selected tab labels. + /// + /// The color specified in [labelStyle] and [TabBarThemeData.labelStyle] is used + /// to style the label when [labelColor] or [TabBarThemeData.labelColor] are not + /// specified. + /// + /// If [unselectedLabelStyle] is null, then this text style will be used for + /// both selected and unselected label styles. + /// + /// If this property is null, then [TabBarThemeData.labelStyle] will be used. + /// + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall] + /// will be used, otherwise the text style of the [ThemeData.primaryTextTheme]'s + /// [TextTheme.bodyLarge] definition is used. + final TextStyle? labelStyle; + + /// The text style of the unselected tab labels. + /// + /// The color specified in [unselectedLabelStyle] and [TabBarThemeData.unselectedLabelStyle] + /// is used to style the label when [unselectedLabelColor] or [TabBarThemeData.unselectedLabelColor] + /// are not specified. + /// + /// If this property is null, then [TabBarThemeData.unselectedLabelStyle] will be used. + /// + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall] + /// will be used, otherwise then the [labelStyle] value is used. If [labelStyle] is null, + /// the text style of the [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge] + /// definition is used. + final TextStyle? unselectedLabelStyle; + + /// The padding added to each of the tab labels. + /// + /// If there are few tabs with both icon and text and few + /// tabs with only icon or text, this padding is vertically + /// adjusted to provide uniform padding to all tabs. + /// + /// If this property is null, then [kTabLabelPadding] is used. + final EdgeInsetsGeometry? labelPadding; + + /// Defines the ink response focus, hover, and splash colors. + /// + /// If non-null, it is resolved against one of [WidgetState.focused], + /// [WidgetState.hovered], and [WidgetState.pressed]. + /// + /// [WidgetState.pressed] triggers a ripple (an ink splash), per + /// the current Material Design spec. + /// + /// If the overlay color is null or resolves to null, then if [ThemeData.useMaterial3] is + /// false, the default values for [InkResponse.focusColor], [InkResponse.hoverColor], [InkResponse.splashColor], + /// and [InkResponse.highlightColor] will be used instead. If [ThemeData.useMaterial3] + /// if true, the default values are: + /// * selected: + /// * pressed - ThemeData.colorScheme.primary(0.1) + /// * hovered - ThemeData.colorScheme.primary(0.08) + /// * focused - ThemeData.colorScheme.primary(0.1) + /// * pressed - ThemeData.colorScheme.primary(0.1) + /// * hovered - ThemeData.colorScheme.onSurface(0.08) + /// * focused - ThemeData.colorScheme.onSurface(0.1) + final WidgetStateProperty? overlayColor; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.material.tabs.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// individual tab widgets. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// {@endtemplate} + /// + /// If null, then the value of [TabBarThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a long-press + /// will produce a short vibration, when feedback is enabled. + /// + /// Defaults to true. + final bool? enableFeedback; + + /// An optional callback that's called when the [VerticalTabBar] is tapped. + /// + /// The callback is applied to the index of the tab where the tap occurred. + /// + /// This callback has no effect on the default handling of taps. It's for + /// applications that want to do a little extra work when a tab is tapped, + /// even if the tap doesn't change the TabController's index. TabBar [onTap] + /// callbacks should not make changes to the TabController since that would + /// interfere with the default tap handler. + final ValueChanged? onTap; + + /// An optional callback that's called when a [VerticalTab]'s hover state in the + /// [VerticalTabBar] changes. + /// + /// Called when a pointer enters or exits the ink response area of the [VerticalTab]. + /// + /// The value passed to the callback is true if a pointer has entered the + /// [VerticalTab] at `index` and false if a pointer has exited. + /// + /// When hover is moved from one tab directly to another, this will be called + /// twice. First to represent hover exiting the initial tab, and then second + /// for the pointer entering hover over the next tab. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [VerticalTab] in response to hovering over a + /// [VerticalTabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onHover.dart ** + /// {@end-tool} + final TabValueChanged? onHover; + + /// An optional callback that's called when a [VerticalTab]'s focus state in the + /// [VerticalTabBar] changes. + /// + /// Called when the node for the [VerticalTab] at `index` gains or loses focus. + /// + /// The value passed to the callback is true if the node has gained focus for + /// the [VerticalTab] at `index` and false if focus has been lost. + /// + /// When focus is moved from one tab directly to another, this will be called + /// twice. First to represent focus being lost by the initially focused tab, + /// and then second for the next tab gaining focus. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [VerticalTab] based on focus traversal in + /// enclosing [VerticalTabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onFocusChange.dart ** + /// {@end-tool} + final TabValueChanged? onFocusChange; + + /// How the [VerticalTabBar]'s scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// Creates the tab bar's [InkWell] splash factory, which defines + /// the appearance of "ink" splashes that occur in response to taps. + /// + /// Use [NoSplash.splashFactory] to defeat ink splash rendering. For example + /// to defeat both the splash and the hover/pressed overlay, but not the + /// keyboard focused overlay: + /// + /// ```dart + /// TabBar( + /// splashFactory: NoSplash.splashFactory, + /// overlayColor: WidgetStateProperty.resolveWith( + /// (Set states) { + /// return states.contains(WidgetState.focused) ? null : Colors.transparent; + /// }, + /// ), + /// tabs: const [ + /// // ... + /// ], + /// ) + /// ``` + final InteractiveInkFeatureFactory? splashFactory; + + /// Defines the clipping radius of splashes that extend outside the bounds of the tab. + /// + /// This can be useful to match the [BoxDecoration.borderRadius] provided as [indicator]. + /// + /// ```dart + /// TabBar( + /// indicator: BoxDecoration( + /// borderRadius: BorderRadius.circular(40), + /// ), + /// splashBorderRadius: BorderRadius.circular(40), + /// tabs: const [ + /// // ... + /// ], + /// ) + /// ``` + /// + /// If this property is null, it is interpreted as [BorderRadius.zero]. + final BorderRadius? splashBorderRadius; + + /// Specifies the horizontal alignment of the tabs within a [VerticalTabBar]. + /// + /// If [VerticalTabBar.isScrollable] is false, only [TabAlignment.fill] and + /// [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If [VerticalTabBar.isScrollable] is true, only [TabAlignment.start], [TabAlignment.startOffset], + /// and [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If this is null, then the value of [TabBarThemeData.tabAlignment] is used. + /// + /// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is true, + /// then [TabAlignment.startOffset] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + /// + /// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is false, + /// then [TabAlignment.center] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + final TabAlignment? tabAlignment; + + /// Specifies the text scaling behavior for the [VerticalTab] label. + /// + /// If this is null, then the value of [TabBarThemeData.textScaler] is used. If that is + /// also null, then the text scaling behavior is determined by the [MediaQueryData.textScaler] + /// from the ambient [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. + /// + /// See also: + /// * [TextScaler], which is used to scale text based on the device's text scale factor. + final TextScaler? textScaler; + + /// Specifies the animation behavior of the tab indicator. + /// + /// If this is null, then the value of [TabBarThemeData.indicatorAnimation] is used. + /// If that is also null, then the tab indicator will animate linearly if the + /// [indicatorSize] is [TabBarIndicatorSize.tab], otherwise it will animate + /// with an elastic effect if the [indicatorSize] is [TabBarIndicatorSize.label]. + /// + /// {@tool dartpad} + /// This sample shows how to customize the animation behavior of the tab indicator + /// by using the [indicatorAnimation] property. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TabIndicatorAnimation], which specifies the animation behavior of the tab indicator. + final TabIndicatorAnimation? indicatorAnimation; + + /// Returns whether the [VerticalTabBar] contains a tab with both text and icon. + /// + /// [VerticalTabBar] uses this to give uniform padding to all tabs in cases where + /// there are some tabs with both text and icon and some which contain only + /// text or icon. + bool get tabHasTextAndIcon { + for (final Widget item in tabs) { + if (item is PreferredSizeWidget) { + if (item.preferredSize.height == _kTextAndIconTabWidth) { + return true; + } + } + } + return false; + } + + /// Whether this tab bar is a primary tab bar. + /// + /// Otherwise, it is a secondary tab bar. + final bool _isPrimary; + + @override + State createState() => _VerticalTabBarState(); +} + +class _VerticalTabBarState extends State { + ScrollController? _scrollController; + TabController? _controller; + _IndicatorPainter? _indicatorPainter; + int? _currentIndex; + // late double _tabStripWidth; + late List _tabKeys; + EdgeInsetsGeometry _labelPadding = EdgeInsets.zero; + bool _debugHasScheduledValidTabsCountCheck = false; + + @override + void initState() { + super.initState(); + // If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find + // the width of tab widget i. See _IndicatorPainter.indicatorRect(). + _tabKeys = widget.tabs.map((Widget tab) => GlobalKey()).toList(); + // _labelPaddings = List.filled( + // widget.tabs.length, + // EdgeInsets.zero, + // growable: true, + // ); + } + + TabBarThemeData get _defaults { + if (Theme.of(context).useMaterial3) { + return widget._isPrimary + ? _TabsPrimaryDefaultsM3(context, widget.isScrollable) + : _TabsSecondaryDefaultsM3(context, widget.isScrollable); + } else { + return _TabsDefaultsM2(context, widget.isScrollable); + } + } + + Decoration _getIndicator(TabBarIndicatorSize indicatorSize) { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + + if (widget.indicator != null) { + return widget.indicator!; + } + if (tabBarTheme.indicator != null) { + return tabBarTheme.indicator!; + } + + Color color = + widget.indicatorColor ?? + tabBarTheme.indicatorColor ?? + _defaults.indicatorColor!; + // ThemeData tries to avoid this by having indicatorColor avoid being the + // primaryColor. However, it's possible that the tab bar is on a + // Material that isn't the primaryColor. In that case, if the indicator + // color ends up matching the material's color, then this overrides it. + // When that happens, automatic transitions of the theme will likely look + // ugly as the indicator color suddenly snaps to white at one end, but it's + // not clear how to avoid that any further. + // + // The material's color might be null (if it's a transparency). In that case + // there's no good way for us to find out what the color is so we don't. + // + // TODO(xu-baolin): Remove automatic adjustment to white color indicator + // with a better long-term solution. + // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917 + if (widget.automaticIndicatorColorAdjustment && + color.toARGB32() == Material.maybeOf(context)?.color?.toARGB32()) { + color = Colors.white; + } + + final double effectiveIndicatorWeight = theme.useMaterial3 + ? math.max(widget.indicatorWeight, switch (widget._isPrimary) { + true => _TabsPrimaryDefaultsM3.indicatorWeight(indicatorSize), + false => _TabsSecondaryDefaultsM3.indicatorWeight, + }) + : widget.indicatorWeight; + // Only Material 3 primary TabBar with label indicatorSize should be rounded. + final bool primaryWithLabelIndicator = switch (indicatorSize) { + TabBarIndicatorSize.label => widget._isPrimary, + TabBarIndicatorSize.tab => false, + }; + final BorderRadius? effectiveBorderRadius = + theme.useMaterial3 && primaryWithLabelIndicator + // dom + ? BorderRadius.only( + topRight: Radius.circular(effectiveIndicatorWeight), + bottomRight: Radius.circular(effectiveIndicatorWeight), + ) + : null; + return VerticalUnderlineTabIndicator( + borderRadius: effectiveBorderRadius, + borderSide: BorderSide( + // TODO(tahatesser): Make sure this value matches Material 3 Tabs spec + // when `preferredSize`and `indicatorWeight` are updated to support Material 3 + // https://m3.material.io/components/tabs/specs#149a189f-9039-4195-99da-15c205d20e30, + // https://github.com/flutter/flutter/issues/116136 + width: effectiveIndicatorWeight, + color: color, + ), + ); + } + + // If the TabBar is rebuilt with a new tab controller, the caller should + // dispose the old one. In that case the old controller's animation will be + // null and should not be accessed. + bool get _controllerIsValid => _controller?.animation != null; + + void _updateTabController() { + final TabController? newController = + widget.controller ?? DefaultTabController.maybeOf(context); + assert(() { + if (newController == null) { + throw FlutterError( + 'No TabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must either provide an explicit ' + 'TabController using the "controller" property, or you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.\n' + 'In this case, there was neither an explicit controller nor a default controller.', + ); + } + return true; + }()); + + if (newController == _controller) { + return; + } + + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + _controller!.removeListener(_handleTabControllerTick); + } + _controller = newController; + if (_controller != null) { + _controller!.animation!.addListener(_handleTabControllerAnimationTick); + _controller!.addListener(_handleTabControllerTick); + _currentIndex = _controller!.index; + } + } + + void _initIndicatorPainter() { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final TabBarIndicatorSize indicatorSize = + widget.indicatorSize ?? + tabBarTheme.indicatorSize ?? + _defaults.indicatorSize!; + + final _IndicatorPainter? oldPainter = _indicatorPainter; + + final TabIndicatorAnimation defaultTabIndicatorAnimation = + switch (indicatorSize) { + TabBarIndicatorSize.label => TabIndicatorAnimation.elastic, + TabBarIndicatorSize.tab => TabIndicatorAnimation.linear, + }; + + _indicatorPainter = !_controllerIsValid + ? null + : _IndicatorPainter( + controller: _controller!, + indicator: _getIndicator(indicatorSize), + indicatorSize: indicatorSize, + indicatorPadding: widget.indicatorPadding, + tabKeys: _tabKeys, + // Passing old painter so that the constructor can copy some values from it. + old: oldPainter, + labelPadding: _labelPadding, + dividerColor: + widget.dividerColor ?? + tabBarTheme.dividerColor ?? + _defaults.dividerColor, + dividerWidth: + widget.dividerWidth ?? + tabBarTheme.dividerHeight ?? + _defaults.dividerHeight, + showDivider: theme.useMaterial3 && !widget.isScrollable, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + indicatorAnimation: + widget.indicatorAnimation ?? + tabBarTheme.indicatorAnimation ?? + defaultTabIndicatorAnimation, + textDirection: .ltr, + ); + + oldPainter?.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMaterial(context)); + _updateTabController(); + _initIndicatorPainter(); + } + + @override + void didUpdateWidget(VerticalTabBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _updateTabController(); + _initIndicatorPainter(); + // Adjust scroll position. + if (_scrollController != null && _scrollController!.hasClients) { + final ScrollPosition position = _scrollController!.position; + if (position is _TabBarScrollPosition) { + position.markNeedsPixelsCorrection(); + } + } + } else if (widget.indicatorColor != oldWidget.indicatorColor || + widget.indicatorWeight != oldWidget.indicatorWeight || + widget.indicatorSize != oldWidget.indicatorSize || + widget.indicatorPadding != oldWidget.indicatorPadding || + widget.indicator != oldWidget.indicator || + widget.dividerColor != oldWidget.dividerColor || + widget.dividerWidth != oldWidget.dividerWidth || + widget.indicatorAnimation != oldWidget.indicatorAnimation) { + _initIndicatorPainter(); + } + + if (widget.tabs.length > _tabKeys.length) { + final int delta = widget.tabs.length - _tabKeys.length; + _tabKeys.addAll(List.generate(delta, (int n) => GlobalKey())); + // _labelPaddings.addAll( + // List.filled(delta, EdgeInsets.zero), + // ); + } else if (widget.tabs.length < _tabKeys.length) { + _tabKeys.removeRange(widget.tabs.length, _tabKeys.length); + // _labelPaddings.removeRange(widget.tabs.length, _tabKeys.length); + } + } + + @override + void dispose() { + _indicatorPainter!.dispose(); + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + _controller!.removeListener(_handleTabControllerTick); + } + _controller = null; + _scrollController?.dispose(); + // We don't own the _controller Animation, so it's not disposed here. + super.dispose(); + } + + int get maxTabIndex => _indicatorPainter!.maxTabIndex; + + final _mainCtr = Get.find(); + + double _tabScrollOffset( + int index, + double viewportWidth, + double minExtent, + double maxExtent, + ) { + if (!widget.isScrollable) { + return 0.0; + } + double tabCenter = _indicatorPainter!.centerOf(index); + // double paddingStart; + // switch (Directionality.of(context)) { + // case TextDirection.rtl: + // paddingStart = widget.padding?.resolve(TextDirection.rtl).right ?? 0; + // tabCenter = _tabStripWidth - tabCenter; + // case TextDirection.ltr: + // paddingStart = widget.padding?.resolve(TextDirection.ltr).left ?? 0; + // } + // dom + final double paddingTop = + widget.padding?.resolve(TextDirection.ltr).top ?? 0; + return clampDouble( + tabCenter + + paddingTop - + viewportWidth / 2.0 + + (_mainCtr.useBottomNav && (_mainCtr.showBottomBar?.value ?? true) + ? 80.0 + : 0.0), + minExtent, + maxExtent, + ); + } + + double _tabCenteredScrollOffset(int index) { + final ScrollPosition position = _scrollController!.position; + return _tabScrollOffset( + index, + position.viewportDimension, + position.minScrollExtent, + position.maxScrollExtent, + ); + } + + double _initialScrollOffset( + double viewportWidth, + double minExtent, + double maxExtent, + ) { + return _tabScrollOffset( + _currentIndex!, + viewportWidth, + minExtent, + maxExtent, + ); + } + + void _scrollToCurrentIndex() { + final double offset = _tabCenteredScrollOffset(_currentIndex!); + _scrollController!.animateTo( + offset, + duration: kTabScrollDuration, + curve: Curves.ease, + ); + } + + void _scrollToControllerValue() { + final double? leadingPosition = _currentIndex! > 0 + ? _tabCenteredScrollOffset(_currentIndex! - 1) + : null; + final double middlePosition = _tabCenteredScrollOffset(_currentIndex!); + final double? trailingPosition = _currentIndex! < maxTabIndex + ? _tabCenteredScrollOffset(_currentIndex! + 1) + : null; + + final double index = _controller!.index.toDouble(); + final double value = _controller!.animation!.value; + final double offset = switch (value - index) { + -1.0 => leadingPosition ?? middlePosition, + 1.0 => trailingPosition ?? middlePosition, + 0 => middlePosition, + < 0 => + leadingPosition == null + ? middlePosition + : lerpDouble(middlePosition, leadingPosition, index - value)!, + _ => + trailingPosition == null + ? middlePosition + : lerpDouble(middlePosition, trailingPosition, value - index)!, + }; + + _scrollController!.jumpTo(offset); + } + + void _handleTabControllerAnimationTick() { + assert(mounted); + if (!_controller!.indexIsChanging && widget.isScrollable) { + // Sync the TabBar's scroll position with the TabBarView's PageView. + _currentIndex = _controller!.index; + _scrollToControllerValue(); + } + } + + void _handleTabControllerTick() { + if (_controller!.index != _currentIndex) { + _currentIndex = _controller!.index; + if (widget.isScrollable) { + _scrollToCurrentIndex(); + } + } + setState(() { + // Rebuild the tabs after a (potentially animated) index change + // has completed. + }); + } + + // Called each time layout completes. + void _saveTabOffsets( + List tabOffsets, + TextDirection textDirection, + double width, + ) { + // _tabStripWidth = width; + _indicatorPainter?.saveTabOffsets(tabOffsets, textDirection); + } + + void _handleTap(int index) { + assert(index >= 0 && index < widget.tabs.length); + _controller!.animateTo(index); + widget.onTap?.call(index); + } + + Widget _buildStyledTab( + Widget child, + bool isSelected, + Animation animation, + TabBarThemeData defaults, + ) { + return _TabStyle( + animation: animation, + isSelected: isSelected, + isPrimary: widget._isPrimary, + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + labelStyle: widget.labelStyle, + unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: defaults, + child: child, + ); + } + + bool _debugScheduleCheckHasValidTabsCount() { + if (_debugHasScheduledValidTabsCountCheck) { + return true; + } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + _debugHasScheduledValidTabsCountCheck = false; + if (!mounted) { + return; + } + assert(() { + if (_controller!.length != widget.tabs.length) { + throw FlutterError( + "Controller's length property (${_controller!.length}) does not match the " + "number of tabs (${widget.tabs.length}) present in TabBar's tabs property.", + ); + } + return true; + }()); + }, debugLabel: 'TabBar.tabsCountCheck'); + _debugHasScheduledValidTabsCountCheck = true; + return true; + } + + bool _debugTabAlignmentIsValid(TabAlignment tabAlignment) { + assert(() { + if (widget.isScrollable && tabAlignment == TabAlignment.fill) { + throw FlutterError( + '$tabAlignment is only valid for non-scrollable tab bars.', + ); + } + if (!widget.isScrollable && + (tabAlignment == TabAlignment.start || + tabAlignment == TabAlignment.startOffset)) { + throw FlutterError( + '$tabAlignment is only valid for scrollable tab bars.', + ); + } + return true; + }()); + return true; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + assert(_debugScheduleCheckHasValidTabsCount()); + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final TabAlignment effectiveTabAlignment = + widget.tabAlignment ?? + tabBarTheme.tabAlignment ?? + _defaults.tabAlignment!; + assert(_debugTabAlignmentIsValid(effectiveTabAlignment)); + + // final MaterialLocalizations localizations = MaterialLocalizations.of( + // context, + // ); + if (_controller!.length == 0) { + return LimitedBox( + maxWidth: 0.0, + child: SizedBox( + width: double.infinity, + height: _kTabWidth + widget.indicatorWeight, + ), + ); + } + + final List wrappedTabs = List.generate(widget.tabs.length, ( + int index, + ) { + EdgeInsetsGeometry padding = + widget.labelPadding ?? tabBarTheme.labelPadding ?? _kTabLabelPadding; + // const double verticalAdjustment = + // (_kTextAndIconTabWidth - _kTabWidth) / 2.0; + // + // final Widget tab = widget.tabs[index]; + // if (tab is PreferredSizeWidget && + // tab.preferredSize.height == _kTabWidth && + // widget.tabHasTextAndIcon) { + // padding = padding.add( + // const EdgeInsets.symmetric(vertical: verticalAdjustment), + // ); + // } + _labelPadding = padding; + + return Center( + widthFactor: 1.0, + child: Padding( + padding: _labelPadding, + child: KeyedSubtree(key: _tabKeys[index], child: widget.tabs[index]), + ), + ); + }); + + // If the controller was provided by DefaultTabController and we're part + // of a Hero (typically the AppBar), then we will not be able to find the + // controller during a Hero transition. See https://github.com/flutter/flutter/issues/213. + if (_controller != null) { + final int previousIndex = _controller!.previousIndex; + + if (_controller!.indexIsChanging) { + // The user tapped on a tab, the tab controller's animation is running. + assert(_currentIndex != previousIndex); + final Animation animation = _ChangeAnimation(_controller!); + wrappedTabs[_currentIndex!] = _buildStyledTab( + wrappedTabs[_currentIndex!], + true, + animation, + _defaults, + ); + wrappedTabs[previousIndex] = _buildStyledTab( + wrappedTabs[previousIndex], + false, + animation, + _defaults, + ); + } else { + // The user is dragging the TabBarView's PageView left or right. + final int tabIndex = _currentIndex!; + final Animation centerAnimation = _DragAnimation( + _controller!, + tabIndex, + ); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + true, + centerAnimation, + _defaults, + ); + if (_currentIndex! > 0) { + final int tabIndex = _currentIndex! - 1; + final Animation previousAnimation = ReverseAnimation( + _DragAnimation(_controller!, tabIndex), + ); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + false, + previousAnimation, + _defaults, + ); + } + if (_currentIndex! < widget.tabs.length - 1) { + final int tabIndex = _currentIndex! + 1; + final Animation nextAnimation = ReverseAnimation( + _DragAnimation(_controller!, tabIndex), + ); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + false, + nextAnimation, + _defaults, + ); + } + } + } + + // Add the tap handler to each tab. If the tab bar is not scrollable, + // then give all of the tabs equal flexibility so that they each occupy + // the same share of the tab bar's overall width. + final int tabCount = widget.tabs.length; + for (int index = 0; index < tabCount; index += 1) { + final Set selectedState = { + if (index == _currentIndex) WidgetState.selected, + }; + + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs( + widget.mouseCursor, + selectedState, + ) ?? + tabBarTheme.mouseCursor?.resolve(selectedState) ?? + WidgetStateMouseCursor.clickable.resolve(selectedState); + + final WidgetStateProperty defaultOverlay = + WidgetStateProperty.resolveWith(( + Set states, + ) { + final Set effectiveStates = selectedState.toSet() + ..addAll(states); + return _defaults.overlayColor?.resolve(effectiveStates); + }); + wrappedTabs[index] = InkWell( + mouseCursor: effectiveMouseCursor, + onTap: () { + _handleTap(index); + }, + onHover: (bool value) { + widget.onHover?.call(value, index); + }, + onFocusChange: (bool value) { + widget.onFocusChange?.call(value, index); + }, + enableFeedback: widget.enableFeedback ?? true, + overlayColor: + widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay, + splashFactory: + widget.splashFactory ?? + tabBarTheme.splashFactory ?? + _defaults.splashFactory, + borderRadius: + widget.splashBorderRadius ?? + tabBarTheme.splashBorderRadius ?? + _defaults.splashBorderRadius, + child: Padding( + padding: EdgeInsets.only(left: widget.indicatorWeight), + child: wrappedTabs[index], + ), + ); + wrappedTabs[index] = MergeSemantics(child: wrappedTabs[index]); + if (!widget.isScrollable && effectiveTabAlignment == TabAlignment.fill) { + wrappedTabs[index] = Expanded(child: wrappedTabs[index]); + } + } + + Widget tabBar = Semantics( + role: SemanticsRole.tabBar, + container: true, + explicitChildNodes: true, + child: CustomPaint( + painter: _indicatorPainter, + child: _TabStyle( + animation: kAlwaysDismissedAnimation, + isSelected: false, + isPrimary: widget._isPrimary, + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + labelStyle: widget.labelStyle, + unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: _defaults, + child: _TabLabelBar( + onPerformLayout: _saveTabOffsets, + mainAxisSize: effectiveTabAlignment == TabAlignment.fill + ? MainAxisSize.max + : MainAxisSize.min, + children: wrappedTabs, + ), + ), + ), + ); + + if (widget.isScrollable) { + _scrollController ??= _TabBarScrollController(this); + tabBar = ScrollConfiguration( + // The scrolling tabs should not show an overscroll indicator. + behavior: ScrollConfiguration.of(context).copyWith(overscroll: false), + child: SingleChildScrollView( + dragStartBehavior: widget.dragStartBehavior, + scrollDirection: Axis.vertical, // dom + controller: _scrollController, + padding: widget.padding, + physics: widget.physics, + child: tabBar, + ), + ); + if (theme.useMaterial3) { + final AlignmentGeometry effectiveAlignment = + switch (effectiveTabAlignment) { + TabAlignment.center => Alignment.center, + TabAlignment.start || + TabAlignment.startOffset || + TabAlignment.fill => AlignmentDirectional.topCenter, // dom + }; + + final Color dividerColor = + widget.dividerColor ?? + tabBarTheme.dividerColor ?? + _defaults.dividerColor!; + final double dividerWidth = + widget.dividerWidth ?? + tabBarTheme.dividerHeight ?? + _defaults.dividerHeight!; + + tabBar = Align( + widthFactor: 1.0, + heightFactor: null, // dom + // heightFactor: dividerWidth > 0 ? null : 1.0, + alignment: effectiveAlignment, + child: tabBar, + ); + + if (dividerColor != Colors.transparent && dividerWidth > 0) { + tabBar = CustomPaint( + painter: _DividerPainter( + dividerColor: dividerColor, + dividerWidth: dividerWidth, + ), + child: tabBar, + ); + } + } + } else if (widget.padding != null) { + tabBar = Padding(padding: widget.padding!, child: tabBar); + } + + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: widget.textScaler ?? tabBarTheme.textScaler), + child: tabBar, + ); + } +} + +// Hand coded defaults based on Material Design 2. +class _TabsDefaultsM2 extends TabBarThemeData { + _TabsDefaultsM2(this.context, this.isScrollable) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final bool isDark = Theme.brightnessOf(context) == Brightness.dark; + late final Color primaryColor = isDark ? Colors.grey[900]! : Colors.blue; + final bool isScrollable; + + @override + Color? get indicatorColor => + _colors.secondary == primaryColor ? Colors.white : _colors.secondary; + + @override + Color? get labelColor => Theme.of(context).primaryTextTheme.bodyLarge!.color!; + + @override + TextStyle? get labelStyle => Theme.of(context).primaryTextTheme.bodyLarge; + + @override + TextStyle? get unselectedLabelStyle => + Theme.of(context).primaryTextTheme.bodyLarge; + + @override + InteractiveInkFeatureFactory? get splashFactory => + Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => + isScrollable ? TabAlignment.start : TabAlignment.fill; + + static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 10); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Tabs + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// dart format off +class _TabsPrimaryDefaultsM3 extends TabBarThemeData { + _TabsPrimaryDefaultsM3(this.context, this.isScrollable) + : super(indicatorSize: TabBarIndicatorSize.label); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; + + // This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.color' token. + @override + Color? get dividerColor => _colors.outlineVariant; + + // This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.height' token. + @override + double? get dividerHeight => 1.0; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.primary; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + WidgetStateProperty get overlayColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withValues(alpha:0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withValues(alpha:0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withValues(alpha:0.1); + } + return null; + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withValues(alpha:0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withValues(alpha:0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withValues(alpha:0.1); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; + + static double indicatorWeight(TabBarIndicatorSize indicatorSize) { + return switch (indicatorSize) { + TabBarIndicatorSize.label => 3.0, + TabBarIndicatorSize.tab => 2.0, + }; + } + + // TODO(davidmartos96): This value doesn't currently exist in + // https://m3.material.io/components/tabs/specs + // Update this when the token is available. + static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 2); +} + +class _TabsSecondaryDefaultsM3 extends TabBarThemeData { + _TabsSecondaryDefaultsM3(this.context, this.isScrollable) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; + + // This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.color' token. + @override + Color? get dividerColor => _colors.outlineVariant; + + // This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.height' token. + @override + double? get dividerHeight => 1.0; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.onSurface; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + WidgetStateProperty get overlayColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withValues(alpha:0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withValues(alpha:0.1); + } + return null; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withValues(alpha:0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withValues(alpha:0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withValues(alpha:0.1); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; + + static double indicatorWeight = 2.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Tabs + +/// Used with [TabBar.indicator] to draw a horizontal line below the +/// selected tab. +/// +/// The selected tab underline is inset from the tab's boundary by [insets]. +/// The [borderSide] defines the line's color and weight. +/// +/// The [TabBar.indicatorSize] property can be used to define the indicator's +/// bounds in terms of its (centered) widget with [TabBarIndicatorSize.label], +/// or the entire tab with [TabBarIndicatorSize.tab]. +class VerticalUnderlineTabIndicator extends Decoration { + /// Create an underline style selected tab indicator. + const VerticalUnderlineTabIndicator({ + this.borderRadius, + this.borderSide = const BorderSide(width: 2.0, color: Colors.white), + this.insets = EdgeInsets.zero, + }); + + /// The radius of the indicator's corners. + /// + /// If this value is non-null, rounded rectangular tab indicator is + /// drawn, otherwise rectangular tab indicator is drawn. + final BorderRadius? borderRadius; + + /// The color and weight of the horizontal line drawn below the selected tab. + final BorderSide borderSide; + + /// Locates the selected tab's underline relative to the tab's boundary. + /// + /// The [TabBar.indicatorSize] property can be used to define the tab + /// indicator's bounds in terms of its (centered) tab widget with + /// [TabBarIndicatorSize.label], or the entire tab with + /// [TabBarIndicatorSize.tab]. + final EdgeInsetsGeometry insets; + + @override + Decoration? lerpFrom(Decoration? a, double t) { + if (a is VerticalUnderlineTabIndicator) { + return VerticalUnderlineTabIndicator( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!, + ); + } + return super.lerpFrom(a, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + if (b is VerticalUnderlineTabIndicator) { + return VerticalUnderlineTabIndicator( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!, + ); + } + return super.lerpTo(b, t); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _VerticalUnderlinePainter(this, borderRadius, onChanged); + } + + Rect _indicatorRectFor(Rect rect, TextDirection textDirection) { + final Rect indicator = insets.resolve(textDirection).deflateRect(rect); + // dom + return Rect.fromLTWH( + indicator.left, + indicator.top, + borderSide.width, + indicator.height, + ); + } + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + if (borderRadius != null) { + return Path()..addRRect( + borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)), + ); + } + return Path()..addRect(_indicatorRectFor(rect, textDirection)); + } +} + +class _VerticalUnderlinePainter extends BoxPainter { + _VerticalUnderlinePainter( + this.decoration, + this.borderRadius, + super.onChanged, + ); + + final VerticalUnderlineTabIndicator decoration; + final BorderRadius? borderRadius; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + assert(configuration.size != null); + final Rect rect = offset & configuration.size!; + final TextDirection textDirection = configuration.textDirection!; + final Paint paint; + if (borderRadius != null) { + paint = Paint()..color = decoration.borderSide.color; + final Rect indicator = decoration._indicatorRectFor(rect, textDirection); + final RRect rrect = RRect.fromRectAndCorners( + indicator, + topLeft: borderRadius!.topLeft, + topRight: borderRadius!.topRight, + bottomRight: borderRadius!.bottomRight, + bottomLeft: borderRadius!.bottomLeft, + ); + canvas.drawRRect(rrect, paint); + } else { + paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square; + final Rect indicator = decoration + ._indicatorRectFor(rect, textDirection) + .deflate(decoration.borderSide.width / 2.0); + // dom + canvas.drawLine(indicator.topLeft, indicator.bottomLeft, paint); + } + } +} diff --git a/lib/pages/rank/controller.dart b/lib/pages/rank/controller.dart index 5062cffaa..4a1421d0b 100644 --- a/lib/pages/rank/controller.dart +++ b/lib/pages/rank/controller.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'package:PiliPlus/models/common/rank_type.dart'; import 'package:PiliPlus/pages/common/common_controller.dart'; -import 'package:PiliPlus/pages/main/controller.dart'; import 'package:PiliPlus/pages/rank/zone/controller.dart'; -import 'package:flutter/foundation.dart' show clampDouble; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -21,27 +19,6 @@ class RankController extends GetxController @override ScrollController get scrollController => controller.scrollController; - final _mainCtr = Get.find(); - - final tabScrollController = ScrollController(); - - void scrollToCurrentIndex(double tabHeight, int index) { - final position = tabScrollController.position; - final offset = clampDouble( - (tabHeight * (2 * index + 1) - position.viewportDimension) / 2.0 + - (_mainCtr.useBottomNav && (_mainCtr.showBottomBar?.value ?? true) - ? 80.0 - : 0.0), - position.minScrollExtent, - position.maxScrollExtent, - ); - tabScrollController.animateTo( - offset, - duration: kTabScrollDuration, - curve: Curves.ease, - ); - } - @override void onInit() { super.onInit(); @@ -51,7 +28,6 @@ class RankController extends GetxController @override void onClose() { tabController.dispose(); - tabScrollController.dispose(); super.onClose(); } diff --git a/lib/pages/rank/view.dart b/lib/pages/rank/view.dart index ec2dbc304..cb74849ed 100644 --- a/lib/pages/rank/view.dart +++ b/lib/pages/rank/view.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/widgets/flutter/vertical_tabs.dart'; import 'package:PiliPlus/models/common/rank_type.dart'; import 'package:PiliPlus/pages/rank/controller.dart'; import 'package:PiliPlus/pages/rank/zone/view.dart'; @@ -43,76 +44,24 @@ class _RankPageState extends State ); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _tabHeight = MediaQuery.textScalerOf(context).scale(21) + 14; - } - - late double _tabHeight; - Widget _buildTab(ThemeData theme) { - return SizedBox( - width: 64, - child: Obx(() { - final tabIndex = _rankController.tabIndex.value; - return ListView.builder( - controller: _rankController.tabScrollController, - padding: .only(bottom: MediaQuery.paddingOf(context).bottom + 105), - itemCount: RankType.values.length, - itemBuilder: (context, index) { - final item = RankType.values[index]; - final isCurr = index == tabIndex; - return SizedBox( - height: _tabHeight, - child: Material( - color: isCurr - ? theme.colorScheme.onInverseSurface - : theme.colorScheme.surface, - child: InkWell( - onTap: isCurr - ? _rankController.animateToTop - : () => _rankController - ..tabIndex.value = index - ..tabController.animateTo(index) - ..scrollToCurrentIndex(_tabHeight, index), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isCurr) - Container( - width: 3, - height: double.infinity, - color: theme.colorScheme.primary, - ) - else - const SizedBox(width: 3), - Expanded( - flex: 1, - child: Container( - alignment: Alignment.center, - padding: const .symmetric(vertical: 7), - child: Text( - item.label, - style: isCurr - ? TextStyle( - fontSize: 15, - color: theme.colorScheme.primary, - ) - : const TextStyle(fontSize: 15), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - }), + return VerticalTabBar( + dividerWidth: 0, + isScrollable: true, + indicatorWeight: 3, + indicatorSize: .tab, + controller: _rankController.tabController, + padding: .only(bottom: MediaQuery.paddingOf(context).bottom + 105), + tabs: RankType.values.map((e) => VerticalTab(text: e.label)).toList(), + onTap: (index) { + if (!_rankController.tabController.indexIsChanging) { + _rankController.animateToTop(); + } else { + _rankController + ..tabIndex.value = index + ..tabController.animateTo(index); + } + }, ); } }