diff --git a/packages/flet/lib/src/controls/control_widget.dart b/packages/flet/lib/src/controls/control_widget.dart index 8b1e331e2..ae4db35a9 100644 --- a/packages/flet/lib/src/controls/control_widget.dart +++ b/packages/flet/lib/src/controls/control_widget.dart @@ -1,15 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../extensions/control.dart'; import '../flet_backend.dart'; import '../models/control.dart'; import '../utils/keys.dart'; -import '../utils/numbers.dart'; -import '../utils/theme.dart'; import '../widgets/control_inherited_notifier.dart'; import '../widgets/error.dart'; +/// Builds the Flutter [Widget] for a [Control]. +/// +/// Responsibilities: +/// - Resolves a stable Flutter [Key] from the control's `key` property (including +/// registering a scroll [GlobalKey] with the backend). +/// - Delegates widget creation to registered extensions. +/// - Builds the result in this control's standard "control context": +/// [ControlInheritedNotifier] (reactivity) and per-control theme overrides. class ControlWidget extends StatelessWidget { final Control control; @@ -20,6 +24,7 @@ class ControlWidget extends StatelessWidget { ControlKey? controlKey = control.getKey("key"); Key? key; if (controlKey is ControlScrollKey) { + // A scroll key needs to be a GlobalKey so the backend can access state. key = GlobalKey(); FletBackend.of(context).globalKeys[controlKey.toString()] = key as GlobalKey; @@ -27,64 +32,12 @@ class ControlWidget extends StatelessWidget { key = ValueKey(controlKey.value); } - Widget? widget; - if (control.internals?["skip_inherited_notifier"] == true) { + return control.buildInControlContext((context) { for (var extension in FletBackend.of(context).extensions) { - widget = extension.createWidget(key, control); + final widget = extension.createWidget(key, control); if (widget != null) return widget; } - widget = ErrorControl("Unknown control: ${control.type}"); - } else { - widget = ControlInheritedNotifier( - notifier: control, - child: Builder(builder: (context) { - ControlInheritedNotifier.of(context); - - Widget? cw; - for (var extension in FletBackend.of(context).extensions) { - cw = extension.createWidget(key, control); - if (cw != null) return cw; - } - - return ErrorControl("Unknown control: ${control.type}"); - }), - ); - } - - // Return original widget if no theme is defined - final isRootControl = control == FletBackend.of(context).page; - final hasNoThemes = control.getString("theme") == null && - control.getString("dark_theme") == null; - final themeMode = control.getThemeMode("theme_mode"); - - if (isRootControl || (hasNoThemes && themeMode == null)) { - return widget; - } - - // Wrap in Theme widget - final ThemeData? parentTheme = - (themeMode == null) ? Theme.of(context) : null; - - Widget buildTheme(Brightness? brightness) { - final themeProp = brightness == Brightness.dark ? "dark_theme" : "theme"; - final themeData = parseTheme(control.get(themeProp), context, brightness, - parentTheme: parentTheme); - return Theme(data: themeData, child: widget!); - } - - if (themeMode == ThemeMode.system) { - final brightness = context.select( - (backend) => backend.platformBrightness, - ); - return buildTheme(brightness); - } - - if (themeMode == ThemeMode.light) { - return buildTheme(Brightness.light); - } else if (themeMode == ThemeMode.dark) { - return buildTheme(Brightness.dark); - } else { - return buildTheme(parentTheme?.brightness); - } + return ErrorControl("Unknown control: ${control.type}"); + }); } } diff --git a/packages/flet/lib/src/controls/tabs.dart b/packages/flet/lib/src/controls/tabs.dart index 31c9191fa..854d1d7b7 100644 --- a/packages/flet/lib/src/controls/tabs.dart +++ b/packages/flet/lib/src/controls/tabs.dart @@ -7,6 +7,7 @@ import '../utils/animations.dart'; import '../utils/borders.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; +import '../utils/keys.dart'; import '../utils/layout.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; @@ -14,8 +15,10 @@ import '../utils/numbers.dart'; import '../utils/tabs.dart'; import '../utils/text.dart'; import '../utils/time.dart'; +import '../widgets/control_inherited_notifier.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; +import 'control_widget.dart'; /// Default duration for tab animation if none is provided. const Duration kDefaultTabAnimationDuration = Duration(milliseconds: 100); @@ -202,23 +205,47 @@ class TabBarViewControl extends StatelessWidget { } } -class TabControl extends StatelessWidget { +class TabControl extends Tab { final Control control; - const TabControl({super.key, required this.control}); + TabControl({super.key, required this.control}) + : super( + // These values are *hints* for Flutter's TabBar heuristics. + // + // TabBar applies different sizing/ink behavior when items in `tabs:` + // are actual `Tab` instances (it literally checks `tab is Tab`). + // In Flet, the real content is built from `control` in `build()`, + // but providing `text`/`icon` here lets TabBar pick the correct + // default height (text vs text+icon) and ensures consistent hover/ + // splash overlay sizing (see issue #5599). + text: control.buildTextOrWidget("label") != null ? "" : null, + icon: control.buildIconOrWidget("icon"), + ); + + static Key? _keyFromControl(Control control) { + final controlKey = control.getKey("key"); + if (controlKey is ControlValueKey) { + return ValueKey(controlKey.value); + } + return null; + } @override Widget build(BuildContext context) { debugPrint("TabControl build: ${control.id}"); - return BaseControl( + return control.buildInControlContext((context) { + return BaseControl( control: control, child: Tab( + key: _keyFromControl(control), icon: control.buildIconOrWidget("icon"), height: control.getDouble("height"), iconMargin: control.getMargin("icon_margin"), child: control.buildTextOrWidget("label"), - )); + ), + ); + }); } } @@ -268,7 +295,19 @@ class _TabBarControlState extends State { .getTextStyle("unselected_label_text_style", Theme.of(context)); var splashBorderRadius = widget.control.getBorderRadius("splash_border_radius"); - var tabs = widget.control.buildWidgets("tabs"); + final tabs = widget.control.children("tabs").map((tab) { + // Ensure parent gets rebuilt when a tab becomes visible/invisible. + tab.notifyParent = true; + + if (tab.type == "Tab") { + return TabControl(control: tab); + } + + // TabBar applies consistent sizing/ink behavior only when `tab is Tab`. + // Wrapping arbitrary controls into a `Tab` keeps hover/splash sizes aligned + // with the tab bar. + return Tab(child: ControlWidget(control: tab)); + }).toList(); void onTap(int index) { widget.control.triggerEvent("click", index); diff --git a/packages/flet/lib/src/widgets/control_inherited_notifier.dart b/packages/flet/lib/src/widgets/control_inherited_notifier.dart index 8357f2b95..75a546de0 100644 --- a/packages/flet/lib/src/widgets/control_inherited_notifier.dart +++ b/packages/flet/lib/src/widgets/control_inherited_notifier.dart @@ -1,8 +1,15 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../extensions/control.dart'; +import '../flet_backend.dart'; import '../models/control.dart'; +import '../utils/theme.dart'; -/// InheritedNotifier for Control. +/// InheritedNotifier for a [Control]. +/// +/// Used to rebuild a control subtree when the +/// corresponding [Control] (a [ChangeNotifier]) changes. class ControlInheritedNotifier extends InheritedNotifier { const ControlInheritedNotifier({ super.key, @@ -10,6 +17,8 @@ class ControlInheritedNotifier extends InheritedNotifier { required super.child, }) : super(); + /// Establishes a dependency on the nearest [ControlInheritedNotifier] and + /// returns its [Control]. static Control? of(BuildContext context) { return context .dependOnInheritedWidgetOfExactType() @@ -21,3 +30,104 @@ class ControlInheritedNotifier extends InheritedNotifier { return notifier != oldWidget.notifier; } } + +/// Wraps [builder] with [ControlInheritedNotifier], unless the control opts out. +/// +/// If `"skip_inherited_notifier"` internal is `true`, this returns +/// [builder] without a [ControlInheritedNotifier] wrapper. +Widget withControlInheritedNotifier(Control control, WidgetBuilder builder) { + if (control.internals?["skip_inherited_notifier"] == true) { + return Builder(builder: builder); + } + + return ControlInheritedNotifier( + notifier: control, + child: Builder(builder: (context) { + ControlInheritedNotifier.of(context); + return builder(context); + }), + ); +} + +/// Convenience wrapper that applies both: +/// - [withControlInheritedNotifier] +/// - [withControlTheme] +Widget withControlContext( + Control control, + WidgetBuilder builder, +) { + // Avoid stacking Builders for controls that opt out of the inherited notifier + // pattern. `withControlTheme()` is also a no-op for these controls. + if (control.internals?["skip_inherited_notifier"] == true) { + return Builder(builder: builder); + } + + return Builder(builder: (context) { + final child = withControlInheritedNotifier(control, builder); + return withControlTheme(control, context, child); + }); +} + +/// Applies per-control theming (`theme`, `dark_theme`, `theme_mode`) to `child`. +/// +/// Returns `child` unchanged when: +/// - `control` is the page/root control +/// - `"skip_inherited_notifier"` internal is `true` +/// - no `theme`/`dark_theme` is set and `theme_mode` is `null` +/// +/// Parameters: +/// - `control`: the control whose per-control theme (if any) will be applied. +/// - `context`: used to access `FletBackend` and the ambient `Theme`. +/// - `child`: the widget subtree to wrap with the per-control `Theme`. +Widget withControlTheme(Control control, BuildContext context, Widget child) { + if (control == FletBackend.of(context).page) return child; + + if (control.internals?["skip_inherited_notifier"] == true) return child; + + final hasNoThemes = + control.get("theme") == null && control.get("dark_theme") == null; + final themeMode = control.getThemeMode("theme_mode"); + if (hasNoThemes && themeMode == null) return child; + + final ThemeData? parentTheme = (themeMode == null) ? Theme.of(context) : null; + + /// Converts [ThemeMode] to [Brightness] used by [Control.getTheme]. + Brightness? themeModeToBrightness(ThemeMode? mode) { + switch (mode) { + case ThemeMode.light: + return Brightness.light; + case ThemeMode.dark: + return Brightness.dark; + case ThemeMode.system: + return context.select( + (backend) => backend.platformBrightness, + ); + case null: + return parentTheme?.brightness; + } + } + + Widget buildTheme(Brightness? brightness) { + final themeData = control.getTheme( + brightness == Brightness.dark ? "dark_theme" : "theme", + context, + brightness, + parentTheme: parentTheme, + ); + return Theme(data: themeData, child: child); + } + + return buildTheme(themeModeToBrightness(themeMode)); +} + +extension ControlContextBuilder on Control { + /// Builds a widget under this control's standard "control context": + /// [ControlInheritedNotifier] + per-control theme wrapping. + /// + /// This is primarily used by [ControlWidget] and any "special" controls that + /// must subclass a Flutter widget (e.g. a control that must be a `Tab`) but + /// still need the same wrapper behavior as a normal `ControlWidget`. + Widget buildInControlContext(WidgetBuilder builder) { + return withControlContext(this, builder); + } +}