diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5e771bb5..eddd7c5fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ ### Bug fixes * Fix `use_state()` and `use_ref()` type inference for callable initializers in mypy and ty ([#6960](https://github.com/flet-dev/flet/issues/6960), [#7014](https://github.com/flet-dev/flet/pull/7014), [#7023](https://github.com/flet-dev/flet/pull/7023)) by @mmporong. +* Fix `BottomAppBar` cutting its notch away from the floating action button when the bar has a left or top `margin`, including an animated one ([#6986](https://github.com/flet-dev/flet/issues/6986), [#7024](https://github.com/flet-dev/flet/pull/7024)) by @ndonkoHenri. +* Fix `Theme.bottom_appbar_theme` being ignored, so its `bgcolor`, `shape` and other settings never reached `BottomAppBar` ([#7024](https://github.com/flet-dev/flet/pull/7024)) by @ndonkoHenri. * Fix `flet build` apps showing a blank page when the system suspended them while Python was starting, such as when iOS launches an app in the background for a notification action ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. * Fix `flet build` naming a Linux app's icons `None.png` and leaving the iOS frameworks it builds from the app's packages under shared identifiers when no `bundle_id` is set ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. * Fix `flet build` writing numbers in `[tool.flet.macos.entitlement]`, `[tool.flet.ios.info]` and `[tool.flet.macos.info]` as strings, producing invalid plists when keys or values contain `&` or `<`, and failing when a `pyproject.toml` value contains `{{` ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index 5e120f5488..bf22be7e9f 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.1.0 * Flutter updated to [3.47.8](https://github.com/flutter/flutter/blob/stable/CHANGELOG.md#3478). +* `BottomAppBarControl` shifts its notch by the bar's margin, following an `animate_margin` animation frame by frame, also for a notch shape from `BottomAppBarTheme`. `parseTheme()` reads the bottom app bar theme from `bottom_appbar_theme` and its color from `bgcolor`, the keys the Python `Theme` sends ([#6986](https://github.com/flet-dev/flet/issues/6986), [#7024](https://github.com/flet-dev/flet/pull/7024)) by @ndonkoHenri. * Update `file_picker` to 13.x (federated; iOS picks media with `PHPickerViewController`, dropping DKImagePickerController, whose camera and photo-library references made App Store Connect demand purpose strings). `FilePickerService` uses `pickFile()`/`pickFiles()`, reads bytes and uploads through `PlatformFile.readAsBytes()`/`readAsByteStream()`, passes `lockParentWindow` through `WindowsOptions`/`LinuxOptions` and `cancel_upload_on_window_blur` through `file_picker_web`'s `FilePickerWebOptions` (imported only on web). `saveFile()` always writes the file, so desktop saves without `src_bytes` create it empty. Bump `device_info_plus` to ^13.3.0, `package_info_plus` to ^10.2.2 and `share_plus` to ^13.3.1 (`win32` 6, required by file_picker 13's Windows implementation) by @FeodorFitsner. * Apply `min_width`/`max_width`/`min_height`/`max_height` to every `LayoutControl` as a `ConstrainedBox` around the control (outside any fixed size). A control that implements one of these names itself lists it in `skip_properties` (`ListTile.min_height`, `NavigationRail.min_width`) by @FeodorFitsner. * Pass `material_tap_target_size` to `Switch` and `ThemeData` (`parseMaterialTapTargetSize()`) by @FeodorFitsner. diff --git a/packages/flet/lib/src/controls/bottom_app_bar.dart b/packages/flet/lib/src/controls/bottom_app_bar.dart index 95fe332269..0a266d11c4 100644 --- a/packages/flet/lib/src/controls/bottom_app_bar.dart +++ b/packages/flet/lib/src/controls/bottom_app_bar.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:material_ui/material_ui.dart'; import '../extensions/control.dart'; import '../models/control.dart'; +import '../utils/animations.dart'; import '../utils/borders.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; @@ -25,28 +27,94 @@ class BottomAppBarControl extends StatelessWidget { borderRadius != null && borderRadius != BorderRadius.zero ? Clip.antiAlias : Clip.none)!; + final shape = control.getNotchedShape( + "shape", theme, BottomAppBarTheme.of(context).shape); + final geometry = + Scaffold.maybeOf(context) != null ? Scaffold.geometryOf(context) : null; + final skipProps = control.internals?["skip_properties"] as List?; + final margin = skipProps?.contains("margin") == true + ? null + : control.getMargin("margin"); + final marginAnimation = control.getAnimation("animate_margin"); + final content = control.buildWidget("content"); - Widget bottomAppBar = BottomAppBar( - clipBehavior: clipBehavior, - padding: control.getPadding("padding"), - height: control.getDouble("height"), - elevation: control.getDouble("elevation"), - shape: control.getNotchedShape("shape", theme), - shadowColor: control.getColor("shadow_color", context), - color: control.getColor("bgcolor", context), - notchMargin: control.getDouble("notch_margin", 4.0)!, - child: control.buildWidget("content"), - ); - - if (borderRadius != null && borderRadius != BorderRadius.zero) { - bottomAppBar = ClipRRect( - borderRadius: borderRadius, - // can't use Clip.none here, so fallback to Clip.antiAlias in that case - clipBehavior: clipBehavior == Clip.none ? Clip.antiAlias : clipBehavior, - child: bottomAppBar, + Widget buildBar(EdgeInsets? margin, Widget? child) { + Widget bar = BottomAppBar( + clipBehavior: clipBehavior, + padding: control.getPadding("padding"), + height: control.getDouble("height"), + elevation: control.getDouble("elevation"), + shape: _shiftNotch(shape, margin, geometry), + shadowColor: control.getColor("shadow_color", context), + color: control.getColor("bgcolor", context), + notchMargin: control.getDouble("notch_margin", 4.0)!, + child: child, ); + if (borderRadius != null && borderRadius != BorderRadius.zero) { + bar = ClipRRect( + borderRadius: borderRadius, + // can't use Clip.none here, so fallback to Clip.antiAlias in that case + clipBehavior: + clipBehavior == Clip.none ? Clip.antiAlias : clipBehavior, + child: bar, + ); + } + return bar; } + // LayoutControl animates an animated margin around the bar, which moves + // the bar without resizing it, so BottomAppBar keeps its notch where it + // was first cut. Animating the same margin here rebuilds the notch for + // every frame of that animation. The clip is built inside the builder, + // so adding or removing a border radius keeps the animation running. + final bottomAppBar = margin != null && marginAnimation != null + ? TweenAnimationBuilder( + tween: EdgeInsetsTween(begin: margin, end: margin), + duration: marginAnimation.duration, + curve: marginAnimation.curve, + builder: (context, margin, child) => buildBar(margin, child), + child: content, + ) + : buildBar(margin, content); + return LayoutControl(control: control, child: bottomAppBar); } } + +/// Moves the notch of [shape] back under the floating action button when the +/// bar has a [margin]. +/// +/// The margin is applied around the bar, but `Scaffold` reports the button's +/// position relative to the whole bottom slot, so the notch would otherwise be +/// cut `margin.left` too far right and `margin.top` too far down. +NotchedShape? _shiftNotch(NotchedShape? shape, EdgeInsets? margin, + ValueListenable? geometry) { + if (shape == null || + margin == null || + (margin.left == 0 && margin.top == 0)) { + return shape; + } + return _OffsetNotchedShape(shape, margin, geometry); +} + +/// A [NotchedShape] that shifts the guest rectangle by [margin] before +/// cutting [inner]'s notch. +/// +/// Outside the bottom slot, such as in the page body, BottomAppBar measures +/// the bar's own top, which already includes `margin.top`, so only the +/// horizontal shift applies there. +class _OffsetNotchedShape extends NotchedShape { + const _OffsetNotchedShape(this.inner, this.margin, this.geometry); + + final NotchedShape inner; + final EdgeInsets margin; + final ValueListenable? geometry; + + @override + Path getOuterPath(Rect host, Rect? guest) { + // Read while BottomAppBar cuts the notch, as it reads the same geometry. + final inBottomSlot = geometry?.value.bottomNavigationBarTop != null; + final offset = Offset(-margin.left, inBottomSlot ? -margin.top : 0); + return inner.getOuterPath(host, guest?.shift(offset)); + } +} diff --git a/packages/flet/lib/src/utils/theme.dart b/packages/flet/lib/src/utils/theme.dart index 0f54e88864..9c93632b19 100644 --- a/packages/flet/lib/src/utils/theme.dart +++ b/packages/flet/lib/src/utils/theme.dart @@ -290,7 +290,7 @@ ThemeData parseTheme( floatingActionButtonTheme: parseFloatingActionButtonTheme( value?["floating_action_button_theme"], theme), bottomAppBarTheme: - parseBottomAppBarTheme(value?["bottom_app_bar_theme"], theme), + parseBottomAppBarTheme(value?["bottom_appbar_theme"], theme), checkboxTheme: parseCheckboxTheme(value?["checkbox_theme"], theme), radioTheme: parseRadioTheme(value?["radio_theme"], theme), badgeTheme: parseBadgeTheme(value?["badge_theme"], theme), @@ -734,7 +734,7 @@ BottomAppBarThemeData? parseBottomAppBarTheme( if (value == null) return defaultValue; return theme.bottomAppBarTheme.copyWith( - color: parseColor(value["color"], theme), + color: parseColor(value["bgcolor"], theme), shadowColor: parseColor(value["shadow_color"], theme), elevation: parseDouble(value["elevation"]), height: parseDouble(value["height"]), diff --git a/packages/flet/test/widgets/bottom_app_bar_test.dart b/packages/flet/test/widgets/bottom_app_bar_test.dart new file mode 100644 index 0000000000..04ceb7fb13 --- /dev/null +++ b/packages/flet/test/widgets/bottom_app_bar_test.dart @@ -0,0 +1,225 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flet/src/controls/bottom_app_bar.dart'; +import 'package:flet/src/flet_backend.dart'; +import 'package:flet/src/flet_core_extension.dart'; +import 'package:flet/src/models/control.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:provider/provider.dart'; + +const int _width = 800; +const int _height = 600; + +/// Pixels skipped at each end of the bar when looking for the notch, so +/// rounded corners don't count as part of it. +const int _cornerInset = 24; + +final FletBackend _backend = FletBackend( + pageUri: Uri.parse('http://localhost'), + assetsDir: '', + extensions: [FletCoreExtension()], + multiView: false); + +Control _bar(Map props) => Control( + id: 2, + type: 'BottomAppBar', + properties: { + 'bgcolor': '#ff0000', + 'elevation': 0, + 'shape': {'_type': 'circular'}, + ...props, + }, + backend: _backend); + +Map _margin({int left = 0, int top = 0, int right = 0}) => + {'left': left, 'top': top, 'right': right, 'bottom': 0}; + +final FloatingActionButton _fab = FloatingActionButton( + onPressed: () {}, + backgroundColor: const Color(0x00000000), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0); + +/// Places the floating action button's top-left corner at [offset]. +class _FixedFabLocation extends FloatingActionButtonLocation { + const _FixedFabLocation(this.offset); + + final Offset offset; + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) => offset; +} + +final GlobalKey _boundaryKey = GlobalKey(); + +Widget _app(Widget scaffold, {ThemeData? theme}) => RepaintBoundary( + key: _boundaryKey, + child: ChangeNotifierProvider.value( + value: _backend, child: MaterialApp(theme: theme, home: scaffold))); + +Widget _slotScaffold(Control bar) => Scaffold( + backgroundColor: const Color(0xFFFFFFFF), + floatingActionButton: _fab, + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: BottomAppBarControl(control: bar), + ); + +Future _setSurfaceSize(WidgetTester tester) async { + await tester.binding + .setSurfaceSize(Size(_width.toDouble(), _height.toDouble())); + addTearDown(() => tester.binding.setSurfaceSize(null)); +} + +bool _isBar(ByteData data, int x, int y) { + final i = (y * _width + x) * 4; + return data.getUint8(i) > 200 && data.getUint8(i + 1) < 60; +} + +/// Measures the notch: its center's horizontal distance from the floating +/// action button's center, and its depth below the bar's top at that center. +Future<(double, int)> _measureNotch(WidgetTester tester) async { + final barRect = tester.getRect(find.byType(BottomAppBar)); + final boundary = + _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final data = (await tester.runAsync(() async { + final image = await boundary.toImage(); + return image.toByteData(format: ui.ImageByteFormat.rawRgba); + }))!; + final y = barRect.top.round() + 3; + final notch = [ + for (var x = barRect.left.round() + _cornerInset; + x < barRect.right.round() - _cornerInset; + x++) + if (!_isBar(data, x, y)) x, + ]; + final fabX = tester.getCenter(find.byType(FloatingActionButton)).dx.round(); + var depth = 0; + for (var yy = barRect.top.round(); yy < barRect.bottom.round(); yy++) { + if (_isBar(data, fabX, yy)) break; + depth++; + } + expect(notch, isNotEmpty, reason: 'the bar has no notch'); + return ((notch.first + notch.last + 1) / 2 - fabX, depth); +} + +/// Pumps the bar from [from] to [to] with a 2 second margin animation and +/// returns the largest notch offset seen during and after the animation. +Future _worstOffsetWhileAnimating(WidgetTester tester, + Map from, Map to) async { + await tester + .pumpWidget(_app(_slotScaffold(_bar({'animate_margin': 2000, ...from})))); + await tester.pumpAndSettle(); + await tester + .pumpWidget(_app(_slotScaffold(_bar({'animate_margin': 2000, ...to})))); + var worst = 0.0; + for (final ms in [0, 500, 500, 1000]) { + await tester.pump(Duration(milliseconds: ms)); + final (dx, _) = await _measureNotch(tester); + if (dx.abs() > worst) worst = dx.abs(); + } + await tester.pumpAndSettle(); + final (dx, _) = await _measureNotch(tester); + return dx.abs() > worst ? dx.abs() : worst; +} + +void main() { + group('BottomAppBar notch with a margin in the bottom slot', () { + for (final margin in [ + null, + _margin(left: 40, right: 40), + _margin(top: 16), + _margin(left: 80), + ]) { + testWidgets('stays under the button with margin $margin', (tester) async { + await _setSurfaceSize(tester); + await tester.pumpWidget(_app(_slotScaffold(_bar({ + if (margin != null) 'margin': margin, + })))); + await tester.pumpAndSettle(); + final (dx, depth) = await _measureNotch(tester); + // The docked button's center is on the slot's top edge, so a top + // margin makes the notch shallower by the same amount. + final top = margin?['top'] ?? 0; + expect(dx.abs(), lessThan(1.5)); + expect(depth, inInclusiveRange(31 - top, 33 - top)); + }); + } + }); + + testWidgets('BottomAppBar notch follows an animated margin', (tester) async { + await _setSurfaceSize(tester); + final worst = await _worstOffsetWhileAnimating( + tester, {'margin': _margin(right: 80)}, {'margin': _margin(left: 80)}); + expect(worst, lessThan(1.5)); + }); + + testWidgets( + 'BottomAppBar notch follows an animated margin when border_radius ' + 'changes at the same time', (tester) async { + await _setSurfaceSize(tester); + final worst = await _worstOffsetWhileAnimating( + tester, + {'margin': _margin(right: 80)}, + {'margin': _margin(left: 80), 'border_radius': 16}); + expect(worst, lessThan(1.5)); + }); + + testWidgets('BottomAppBar notch keeps its depth in the page body', + (tester) async { + await _setSurfaceSize(tester); + // A 16 px top margin, then an 80 px bar whose top is at y=520, where the + // button's center sits. + await tester.pumpWidget(_app(Scaffold( + backgroundColor: const Color(0xFFFFFFFF), + floatingActionButton: _fab, + floatingActionButtonLocation: const _FixedFabLocation(Offset(372, 492)), + body: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: _width.toDouble(), + child: BottomAppBarControl( + control: _bar({'height': 80, 'margin': _margin(top: 16)})), + ), + ), + ))); + await tester.pumpAndSettle(); + final (dx, depth) = await _measureNotch(tester); + expect(dx.abs(), lessThan(1.5)); + expect(depth, inInclusiveRange(31, 33)); + }); + + testWidgets('BottomAppBar notch shape from the theme follows the margin', + (tester) async { + await _setSurfaceSize(tester); + await tester.pumpWidget(_app( + _slotScaffold(_bar({ + 'shape': null, + 'margin': _margin(left: 40, right: 40), + })), + theme: ThemeData( + bottomAppBarTheme: const BottomAppBarThemeData( + shape: CircularNotchedRectangle())))); + await tester.pumpAndSettle(); + final (dx, _) = await _measureNotch(tester); + expect(dx.abs(), lessThan(1.5)); + }); + + testWidgets('BottomAppBar notch ignores a margin in skip_properties', + (tester) async { + await _setSurfaceSize(tester); + await tester.pumpWidget(_app(_slotScaffold(_bar({ + 'margin': _margin(left: 80), + '_internals': { + 'skip_properties': ['margin'], + }, + })))); + await tester.pumpAndSettle(); + final (dx, _) = await _measureNotch(tester); + expect(dx.abs(), lessThan(1.5)); + }); +} diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/notch_with_margin.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/notch_with_margin.png new file mode 100644 index 0000000000..ab39dc70ce Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/notch_with_margin.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/theme_notch_with_margin.png b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/theme_notch_with_margin.png new file mode 100644 index 0000000000..7ac75b9ea1 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/material/golden/macos/bottom_app_bar/theme_notch_with_margin.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/material/test_bottom_app_bar.py b/sdk/python/packages/flet/integration_tests/controls/material/test_bottom_app_bar.py index 2dd0b2a9fa..197d4345fb 100644 --- a/sdk/python/packages/flet/integration_tests/controls/material/test_bottom_app_bar.py +++ b/sdk/python/packages/flet/integration_tests/controls/material/test_bottom_app_bar.py @@ -1,10 +1,17 @@ import pytest +import pytest_asyncio import flet as ft import flet.testing as ftt -@pytest.mark.asyncio(loop_scope="module") +# Create a new flet_app instance for each test method +@pytest_asyncio.fixture(scope="function", autouse=True) +def flet_app(flet_app_function): + return flet_app_function + + +@pytest.mark.asyncio(loop_scope="function") async def test_basic(flet_app: ftt.FletTestApp, request): await flet_app.assert_control_screenshot( request.node.name, @@ -21,3 +28,63 @@ async def test_basic(flet_app: ftt.FletTestApp, request): ), ), ) + + +async def _assert_page_screenshot(flet_app: ftt.FletTestApp, name: str): + flet_app.page.update() + await flet_app.tester.pump_and_settle() + flet_app.assert_screenshot( + name, + await flet_app.page.take_screenshot( + pixel_ratio=flet_app.screenshots_pixel_ratio + ), + ) + + +def _page_with_docked_fab(flet_app: ftt.FletTestApp) -> ft.Page: + # A small window keeps the bar large in the screenshot, so a misplaced + # notch drops similarity below the threshold. + page = flet_app.page + page.enable_screenshots = True + flet_app.resize_page(240, 160) + page.bgcolor = ft.Colors.BLUE_GREY_200 + page.floating_action_button = ft.FloatingActionButton( + icon=ft.Icons.ADD, + shape=ft.CircleBorder(), + bgcolor=ft.Colors.TEAL_700, + foreground_color=ft.Colors.WHITE, + ) + page.floating_action_button_location = ft.FloatingActionButtonLocation.CENTER_DOCKED + return page + + +@pytest.mark.asyncio(loop_scope="function") +async def test_notch_with_margin(flet_app: ftt.FletTestApp): + """The notch stays centered under a docked FAB when the bar has margins.""" + page = _page_with_docked_fab(flet_app) + page.bottom_appbar = ft.BottomAppBar( + bgcolor=ft.Colors.WHITE, + shape=ft.CircularRectangleNotchShape(), + border_radius=36, + margin=ft.Margin.only(left=40, top=16, right=40, bottom=24), + height=70, + ) + await _assert_page_screenshot(flet_app, "notch_with_margin") + + +@pytest.mark.asyncio(loop_scope="function") +async def test_theme_notch_with_margin(flet_app: ftt.FletTestApp): + """A notch shape and color from the page theme apply, centered with margins.""" + page = _page_with_docked_fab(flet_app) + page.theme = ft.Theme( + bottom_appbar_theme=ft.BottomAppBarTheme( + bgcolor=ft.Colors.ORANGE_300, + shape=ft.CircularRectangleNotchShape(), + ) + ) + page.bottom_appbar = ft.BottomAppBar( + border_radius=36, + margin=ft.Margin.only(left=40, top=16, right=40, bottom=24), + height=70, + ) + await _assert_page_screenshot(flet_app, "theme_notch_with_margin") diff --git a/sdk/python/packages/flet/src/flet/controls/material/textfield.py b/sdk/python/packages/flet/src/flet/controls/material/textfield.py index 4718614d05..6f991e9e3a 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/textfield.py +++ b/sdk/python/packages/flet/src/flet/controls/material/textfield.py @@ -240,7 +240,8 @@ class TextInputAction(Enum): """ No action specific to the field; the platform decides how the keyboard looks. - Keeps focus in the field, so the keyboard stays open. + Pressing the action button removes focus from the field, which closes the + keyboard. In a multiline field it inserts a new line instead. Note: Supported on Android only.