Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 13 additions & 60 deletions packages/flet/lib/src/controls/control_widget.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -20,71 +24,20 @@ 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;
} else if (controlKey != null) {
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<FletBackend, Brightness>(
(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}");
});
}
}
49 changes: 44 additions & 5 deletions packages/flet/lib/src/controls/tabs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ 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';
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);
Expand Down Expand Up @@ -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"),
));
),
);
});
}
}

Expand Down Expand Up @@ -268,7 +295,19 @@ class _TabBarControlState extends State<TabBarControl> {
.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);
Expand Down
108 changes: 106 additions & 2 deletions packages/flet/lib/src/widgets/control_inherited_notifier.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
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<Control> {
const ControlInheritedNotifier({
super.key,
super.notifier,
required super.child,
}) : super();

/// Establishes a dependency on the nearest [ControlInheritedNotifier] and
/// returns its [Control].
static Control? of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<ControlInheritedNotifier>()
Expand All @@ -21,3 +30,98 @@ class ControlInheritedNotifier extends InheritedNotifier<Control> {
return notifier != oldWidget.notifier;
}
}

/// Wraps [builder] with [ControlInheritedNotifier], unless the control opts out.
///
/// If `"skip_inherited_notifier"` internal is `true`, this returns
/// [builder] unwrapped to preserve historical semantics.
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,
) {
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<FletBackend, Brightness>(
(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);
}
}
Loading