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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/flet/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
104 changes: 86 additions & 18 deletions packages/flet/lib/src/controls/bottom_app_bar.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<EdgeInsets>(
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<ScaffoldGeometry>? 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<ScaffoldGeometry>? 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));
}
}
4 changes: 2 additions & 2 deletions packages/flet/lib/src/utils/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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"]),
Expand Down
225 changes: 225 additions & 0 deletions packages/flet/test/widgets/bottom_app_bar_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> props) => Control(
id: 2,
type: 'BottomAppBar',
properties: {
'bgcolor': '#ff0000',
'elevation': 0,
'shape': {'_type': 'circular'},
...props,
},
backend: _backend);

Map<String, int> _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<FletBackend>.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<void> _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 = <int>[
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<double> _worstOffsetWhileAnimating(WidgetTester tester,
Map<String, dynamic> from, Map<String, dynamic> 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));
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading