mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
perf(mobile): stop rebuilding Home's channel list when a route covers it (#9459)
Opening any route or sheet above Home (search, a message's action menu, a profile sheet, Activity, Settings) rebuilt Home's whole channel list twice: once when the route started covering Home and once when it finished. On a phone the open channel sits inside Home's conversation pane, so a sheet over a channel (long-press menu, profile) did the same. `HomePage` and `ChannelsPage` read `ModalRoute.of(context)` in their own builds, which depends on every part of the route's state (whether it's current, whether it's offstage, and so on). `ChannelsPage` also depended on the whole `DuoToolbarScope`, whose `active` flag flips whenever a route covers the pane, through `frostedAppBarHeight`. Every channel row and the Solo section depended on `ConversationPaneScope` for the selected channel, and that scope notified all of them when its route-dependent `active` flag flipped. Each of those rebuilt a fresh channel inventory, including Solo rows that had scrolled away, which resubscribed their typing feeds for 30 seconds. Now: - Home's native controls (Activity bell, tab bar, Duo rail) read the route in a small `_NativeControlsGate` leaf, so only they rebuild when Home is covered. - `ChannelsPage` mirrors route currency into its header suppression notifier from a `_RouteCurrencySync` leaf above the header, so the header still switches in the same frame. The two "wait until this route is current again" loops read the same notifier. - `DuoToolbarScope` is an `InheritedModel` with a strip-only lookup, `maybeStripOf`. The frosted header's height and `ChannelsPage` use it, so `active` changes no longer reach them. Every other reader keeps `maybeOf` and its current behavior. - `ConversationPaneScope`'s `active` flag lives in its own private inherited widget, read only by `isActiveOf`. Rows that only need the controller no longer rebuild when it flips. - Opening a channel looks up the conversation pane instead of depending on it. ### Validation New test: opening and closing a sheet above Home rebuilds neither `HomePage`, `ChannelsPage` nor any channel row. It fails without this change. Full mobile test suite, `flutter analyze` and the file-size check pass. On the iPhone 12 mini (profile build, Builderlab): Same scripted run on `main` and on this branch (search opened and closed 3 times, then a message's long-press menu and a profile sheet over an open channel): | | main | this branch | | --- | --- | --- | | Widget builds per search open | 126 to 350 | 56 to 60 | | Build time per search open | 79 to 90 ms | 29 to 41 ms | | Build time, long-press menu open | 64 ms | 17 ms | | Typing subscriptions reopened by the sheets | 9 (about 80 KB of messages downloaded) | 0 | The menu, profile sheet, channel header and Home's tab bar and Activity button behaved the same in both runs. ### Regressions None expected. The native controls and header switch from the same route state as before, in the same frame. A `DuoToolbarScope` strip change still reaches the header and the channel list. --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
This commit is contained in:
@@ -188,7 +188,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
final nativeDuoTitle =
|
||||
messagesFilters != null &&
|
||||
defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
(DuoToolbarScope.maybeOf(context)?.strip ??
|
||||
(DuoToolbarScope.maybeStripOf(context) ??
|
||||
FoldGeometry.of(context).strip) !=
|
||||
null;
|
||||
final filterContentHeight = messagesFilters == null
|
||||
@@ -206,10 +206,8 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
);
|
||||
// A sheet's dim barrier can't composite over Home's native soft edge, so
|
||||
// use the Flutter backdrop while another route covers this page.
|
||||
final route = ModalRoute.of(context);
|
||||
final headerNativeSuppressed = useValueNotifier(false);
|
||||
headerNativeSuppressed.value =
|
||||
messagesFilters != null && !(route?.isCurrent ?? true);
|
||||
final routeCurrent = useValueNotifier(true);
|
||||
final communityAvatarKey = useMemoized(GlobalKey.new);
|
||||
final nativeCommunityAvatarBounds = useRef<Rect?>(null);
|
||||
final headerKey = useMemoized(GlobalKey.new);
|
||||
@@ -310,7 +308,10 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
.toList();
|
||||
Future<void> openChannel(Channel channel) async {
|
||||
if (!context.mounted) return;
|
||||
final pane = ConversationPaneScope.maybeOf(context);
|
||||
// A lookup, not a dependency: the page doesn't rebuild with the pane.
|
||||
final pane = context
|
||||
.getInheritedWidgetOfExactType<ConversationPaneScope>()
|
||||
?.notifier;
|
||||
if (pane != null) {
|
||||
pane.open(
|
||||
channel.id,
|
||||
@@ -431,7 +432,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
do {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (cancelled || !context.mounted) return;
|
||||
} while (ModalRoute.of(context)?.isCurrent == false);
|
||||
} while (!routeCurrent.value);
|
||||
if (ref.read(pairedCommunityLandingProvider)?.id !=
|
||||
arrivingCommunity.id) {
|
||||
return;
|
||||
@@ -500,7 +501,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
do {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (cancelled || !context.mounted) return;
|
||||
} while (ModalRoute.of(context)?.isCurrent == false);
|
||||
} while (!routeCurrent.value);
|
||||
if (!ref.read(communityGridLandingProvider)) return;
|
||||
ref.read(communityGridLandingProvider.notifier).clear();
|
||||
openCommunityGrid();
|
||||
@@ -648,7 +649,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
return () => active = false;
|
||||
}, [newChannelAction, newDmAction, currentPubkey]);
|
||||
|
||||
return ValueListenableBuilder(
|
||||
final page = ValueListenableBuilder(
|
||||
valueListenable: headerTreatment,
|
||||
// Retain the inventory during header animation. Provider and parent
|
||||
// updates still construct a fresh body with current channel data.
|
||||
@@ -840,6 +841,40 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
body: child!,
|
||||
),
|
||||
);
|
||||
return _RouteCurrencySync(
|
||||
current: routeCurrent,
|
||||
nativeSuppressed: headerNativeSuppressed,
|
||||
suppressesNative: messagesFilters != null,
|
||||
child: page,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors whether this page's route is current into [current], and into
|
||||
/// [nativeSuppressed] when [suppressesNative] is set.
|
||||
///
|
||||
/// Only this leaf depends on the route, so a route or sheet opening above Home
|
||||
/// rebuilds it and the header listening to [nativeSuppressed], not the channel
|
||||
/// inventory. The header is a descendant, so it updates in the same frame.
|
||||
class _RouteCurrencySync extends StatelessWidget {
|
||||
const _RouteCurrencySync({
|
||||
required this.current,
|
||||
required this.nativeSuppressed,
|
||||
required this.suppressesNative,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ValueNotifier<bool> current;
|
||||
final ValueNotifier<bool> nativeSuppressed;
|
||||
final bool suppressesNative;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCurrent = ModalRoute.isCurrentOf(context) ?? true;
|
||||
current.value = isCurrent;
|
||||
nativeSuppressed.value = suppressesNative && !isCurrent;
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -640,8 +640,7 @@ class _SectionChevron extends StatelessWidget {
|
||||
|
||||
bool _usesNativeSectionMenu(BuildContext context) =>
|
||||
defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
(DuoToolbarScope.maybeOf(context)?.strip ??
|
||||
FoldGeometry.of(context).strip) !=
|
||||
(DuoToolbarScope.maybeStripOf(context) ?? FoldGeometry.of(context).strip) !=
|
||||
null;
|
||||
|
||||
class _SectionOptionsButton extends StatelessWidget {
|
||||
|
||||
@@ -78,12 +78,6 @@ class HomePage extends HookConsumerWidget {
|
||||
? 375.0
|
||||
: null;
|
||||
final showWorkspaceNavigation = wide || conversations.selectedId == null;
|
||||
final route = ModalRoute.of(context);
|
||||
final coveringProgress = useAnimation(
|
||||
route?.secondaryAnimation ?? kAlwaysDismissedAnimation,
|
||||
);
|
||||
final nativeControlsVisible =
|
||||
(route?.isCurrent ?? true) && coveringProgress == 0;
|
||||
|
||||
final communitySurfaceProgress = useState(0.0);
|
||||
final filter = useState('All');
|
||||
@@ -166,83 +160,85 @@ class HomePage extends HookConsumerWidget {
|
||||
(label: 'Settings', symbol: 'gearshape', onPressed: openSettings),
|
||||
];
|
||||
return [
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
!nativeControlsVisible)
|
||||
const SizedBox(width: 44, height: 44)
|
||||
else
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
defaultTargetPlatform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
icon: IosGlassNavigationIcon.bell,
|
||||
semanticLabel: inboxLabel,
|
||||
width: 44,
|
||||
height: 44,
|
||||
controlSize: 44,
|
||||
foregroundColor: context.colors.onSurface,
|
||||
onPressed: openInbox,
|
||||
)
|
||||
: IconButton.filledTonal(
|
||||
tooltip: inboxLabel,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
context.colors.surfaceContainerHighest,
|
||||
foregroundColor: context.colors.onSurface,
|
||||
minimumSize: const Size.square(48),
|
||||
),
|
||||
icon: const Icon(BuzzIcons.bell),
|
||||
onPressed: openInbox,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ExcludeSemantics(
|
||||
child: Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: hasUnreadInbox ? 1 : 0,
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: SizedBox.square(
|
||||
key: const Key('home-inbox-badge'),
|
||||
dimension: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 2,
|
||||
child: Container(
|
||||
key: const Key('home-inbox-dot'),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.primary,
|
||||
border: Border.all(
|
||||
color:
|
||||
defaultTargetPlatform ==
|
||||
TargetPlatform.android
|
||||
? context
|
||||
.colors
|
||||
.surfaceContainerHighest
|
||||
: context.controlSurface,
|
||||
width: 2,
|
||||
_NativeControlsGate(
|
||||
builder: (_, nativeControlsVisible) =>
|
||||
defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
!nativeControlsVisible
|
||||
? const SizedBox(width: 44, height: 44)
|
||||
: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
defaultTargetPlatform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
icon: IosGlassNavigationIcon.bell,
|
||||
semanticLabel: inboxLabel,
|
||||
width: 44,
|
||||
height: 44,
|
||||
controlSize: 44,
|
||||
foregroundColor: context.colors.onSurface,
|
||||
onPressed: openInbox,
|
||||
)
|
||||
: IconButton.filledTonal(
|
||||
tooltip: inboxLabel,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
context.colors.surfaceContainerHighest,
|
||||
foregroundColor: context.colors.onSurface,
|
||||
minimumSize: const Size.square(48),
|
||||
),
|
||||
icon: const Icon(BuzzIcons.bell),
|
||||
onPressed: openInbox,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ExcludeSemantics(
|
||||
child: Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: hasUnreadInbox ? 1 : 0,
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: SizedBox.square(
|
||||
key: const Key('home-inbox-badge'),
|
||||
dimension: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 2,
|
||||
child: Container(
|
||||
key: const Key('home-inbox-dot'),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.primary,
|
||||
border: Border.all(
|
||||
color:
|
||||
defaultTargetPlatform ==
|
||||
TargetPlatform.android
|
||||
? context
|
||||
.colors
|
||||
.surfaceContainerHighest
|
||||
: context.controlSurface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
SizedBox(
|
||||
key: avatarKey,
|
||||
@@ -330,41 +326,43 @@ class HomePage extends HookConsumerWidget {
|
||||
],
|
||||
);
|
||||
return DuoWorkspaceScaffold(
|
||||
navigationBuilder: (context, placement) =>
|
||||
wide ||
|
||||
!showWorkspaceNavigation ||
|
||||
!nativeControlsVisible ||
|
||||
communitySurfaceProgress.value != 0
|
||||
? const SizedBox.shrink()
|
||||
: Transform.translate(
|
||||
offset: Offset(
|
||||
IosNavigationMetrics.of(context).railCenter(
|
||||
MediaQuery.sizeOf(context),
|
||||
strip!.rect.center.dx,
|
||||
) -
|
||||
strip.rect.center.dx,
|
||||
0,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
// Match the compact composer's safe-area gutter and its
|
||||
// downward resting offset in _ComposerDockFrame.
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
(MediaQuery.viewPaddingOf(context).bottom +
|
||||
Grid.xxs -
|
||||
Grid.twelve -
|
||||
Grid.quarter)
|
||||
.clamp(0.0, double.infinity),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: navigationBar(Axis.vertical),
|
||||
navigationBuilder: (context, placement) => _NativeControlsGate(
|
||||
builder: (_, nativeControlsVisible) =>
|
||||
wide ||
|
||||
!showWorkspaceNavigation ||
|
||||
!nativeControlsVisible ||
|
||||
communitySurfaceProgress.value != 0
|
||||
? const SizedBox.shrink()
|
||||
: Transform.translate(
|
||||
offset: Offset(
|
||||
IosNavigationMetrics.of(context).railCenter(
|
||||
MediaQuery.sizeOf(context),
|
||||
strip!.rect.center.dx,
|
||||
) -
|
||||
strip.rect.center.dx,
|
||||
0,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
// Match the compact composer's safe-area gutter and its
|
||||
// downward resting offset in _ComposerDockFrame.
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
(MediaQuery.viewPaddingOf(context).bottom +
|
||||
Grid.xxs -
|
||||
Grid.twelve -
|
||||
Grid.quarter)
|
||||
.clamp(0.0, double.infinity),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: navigationBar(Axis.vertical),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
extendBody: true,
|
||||
@@ -373,11 +371,13 @@ class HomePage extends HookConsumerWidget {
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: SizedBox(
|
||||
height: mobileTabBarHeight,
|
||||
child:
|
||||
(defaultTargetPlatform != TargetPlatform.iOS ||
|
||||
nativeControlsVisible)
|
||||
? Center(child: navigationBar(Axis.horizontal))
|
||||
: null,
|
||||
child: _NativeControlsGate(
|
||||
builder: (_, nativeControlsVisible) =>
|
||||
(defaultTargetPlatform != TargetPlatform.iOS ||
|
||||
nativeControlsVisible)
|
||||
? Center(child: navigationBar(Axis.horizontal))
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -470,16 +470,22 @@ class HomePage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (nativeControlsVisible)
|
||||
Positioned(
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
bottom: mobileTabBarBottom(paneContext),
|
||||
height: mobileTabBarHeight,
|
||||
child: Center(
|
||||
child: navigationBar(Axis.horizontal),
|
||||
),
|
||||
Positioned(
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
bottom: mobileTabBarBottom(paneContext),
|
||||
height: mobileTabBarHeight,
|
||||
child: _NativeControlsGate(
|
||||
builder: (_, nativeControlsVisible) =>
|
||||
nativeControlsVisible
|
||||
? Center(
|
||||
child: navigationBar(
|
||||
Axis.horizontal,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -506,3 +512,27 @@ class HomePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds [builder] with whether Home's native controls may show: Home is the
|
||||
/// current route and nothing is covering it.
|
||||
///
|
||||
/// Only this leaf depends on Home's route, so a route or sheet opening above
|
||||
/// Home rebuilds these controls and not the channel inventory.
|
||||
class _NativeControlsGate extends HookWidget {
|
||||
const _NativeControlsGate({required this.builder});
|
||||
|
||||
final Widget Function(BuildContext context, bool nativeControlsVisible)
|
||||
builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
final coveringProgress = useAnimation(
|
||||
route?.secondaryAnimation ?? kAlwaysDismissedAnimation,
|
||||
);
|
||||
return builder(
|
||||
context,
|
||||
(route?.isCurrent ?? true) && coveringProgress == 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,14 +102,15 @@ class ConversationPaneController extends ChangeNotifier {
|
||||
|
||||
class ConversationPaneScope
|
||||
extends InheritedNotifier<ConversationPaneController> {
|
||||
const ConversationPaneScope({
|
||||
ConversationPaneScope({
|
||||
required ConversationPaneController controller,
|
||||
required super.child,
|
||||
this.active = true,
|
||||
required Widget child,
|
||||
bool active = true,
|
||||
super.key,
|
||||
}) : super(notifier: controller);
|
||||
|
||||
final bool active;
|
||||
}) : super(
|
||||
notifier: controller,
|
||||
child: _ConversationPaneActive(active: active, child: child),
|
||||
);
|
||||
|
||||
/// Only the selected page owns the pane's implicit root navigation.
|
||||
/// Imperatively pushed pages retain ordinary Back navigation.
|
||||
@@ -121,21 +122,29 @@ class ConversationPaneScope
|
||||
settings.key == controller!.selectionKey;
|
||||
}
|
||||
|
||||
/// Depends only on [active], which flips whenever a route covers the pane,
|
||||
/// so readers of [maybeOf] (rows, the list) don't rebuild with it.
|
||||
static bool isActiveOf(BuildContext context) =>
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<ConversationPaneScope>()
|
||||
.dependOnInheritedWidgetOfExactType<_ConversationPaneActive>()
|
||||
?.active ??
|
||||
true;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ConversationPaneScope oldWidget) =>
|
||||
active != oldWidget.active || super.updateShouldNotify(oldWidget);
|
||||
|
||||
static ConversationPaneController? maybeOf(BuildContext context) => context
|
||||
.dependOnInheritedWidgetOfExactType<ConversationPaneScope>()
|
||||
?.notifier;
|
||||
}
|
||||
|
||||
class _ConversationPaneActive extends InheritedWidget {
|
||||
const _ConversationPaneActive({required this.active, required super.child});
|
||||
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_ConversationPaneActive oldWidget) =>
|
||||
active != oldWidget.active;
|
||||
}
|
||||
|
||||
/// Keeps both the list and its detail navigator mounted across compact/regular
|
||||
/// layouts, so opening or closing Duo does not recreate the composer or thread.
|
||||
class AdaptiveConversationPane extends HookConsumerWidget {
|
||||
|
||||
@@ -18,7 +18,7 @@ bool isCompactDuoWindow(BuildContext context) {
|
||||
}
|
||||
|
||||
/// Carries window geometry through the independently sized conversation panes.
|
||||
class DuoToolbarScope extends InheritedWidget {
|
||||
class DuoToolbarScope extends InheritedModel<Object> {
|
||||
const DuoToolbarScope({
|
||||
required this.strip,
|
||||
required this.active,
|
||||
@@ -35,8 +35,20 @@ class DuoToolbarScope extends InheritedWidget {
|
||||
/// Space above detail actions for the workspace's persistent rail controls.
|
||||
final int leadingSlots;
|
||||
|
||||
static const Object _stripAspect = #strip;
|
||||
|
||||
static DuoToolbarScope? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<DuoToolbarScope>();
|
||||
InheritedModel.inheritFrom<DuoToolbarScope>(context);
|
||||
|
||||
/// The enclosing pane's [strip], depending on nothing else.
|
||||
///
|
||||
/// [active] flips whenever a route covers the pane, so geometry readers
|
||||
/// such as headers and page lists use this to avoid rebuilding with it.
|
||||
static FoldStrip? maybeStripOf(BuildContext context) =>
|
||||
InheritedModel.inheritFrom<DuoToolbarScope>(
|
||||
context,
|
||||
aspect: _stripAspect,
|
||||
)?.strip;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(DuoToolbarScope oldWidget) =>
|
||||
@@ -44,6 +56,12 @@ class DuoToolbarScope extends InheritedWidget {
|
||||
active != oldWidget.active ||
|
||||
contentInsets != oldWidget.contentInsets ||
|
||||
leadingSlots != oldWidget.leadingSlots;
|
||||
|
||||
@override
|
||||
bool updateShouldNotifyDependent(
|
||||
DuoToolbarScope oldWidget,
|
||||
Set<Object> dependencies,
|
||||
) => strip != oldWidget.strip;
|
||||
}
|
||||
|
||||
/// Content clearance for a pane, or a full-window route outside a pane.
|
||||
|
||||
@@ -43,7 +43,7 @@ double _barContentHeight(
|
||||
: scaledTitleHeight;
|
||||
final accessibleHeight = Grid.xxs + effectiveTitleHeight + Grid.xxs;
|
||||
final strip =
|
||||
DuoToolbarScope.maybeOf(context)?.strip ?? FoldGeometry.of(context).strip;
|
||||
DuoToolbarScope.maybeStripOf(context) ?? FoldGeometry.of(context).strip;
|
||||
final minimumHeight =
|
||||
defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
strip != null &&
|
||||
@@ -72,8 +72,9 @@ double frostedAppBarLowerTitleHeight(
|
||||
// Duo has no top status inset in the content panes. Reserve breathing room
|
||||
// around the native 44-point controls instead of collapsing the header to them.
|
||||
double _nativeCompactHeight(BuildContext context, [double titleHeight = 0]) {
|
||||
final scope = DuoToolbarScope.maybeOf(context);
|
||||
final strip = scope == null ? FoldGeometry.of(context).strip : scope.strip;
|
||||
final strip = context.getInheritedWidgetOfExactType<DuoToolbarScope>() == null
|
||||
? FoldGeometry.of(context).strip
|
||||
: DuoToolbarScope.maybeStripOf(context);
|
||||
final height = IosNavigationMetrics.of(context).compactHeight;
|
||||
return strip == null
|
||||
? max(height, titleHeight)
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import 'package:buzz/shared/widgets/navigation_backdrop.dart';
|
||||
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
|
||||
import 'package:buzz/shared/widgets/adaptive_conversation_pane.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/channels/default_agent_launcher.dart';
|
||||
@@ -12,6 +13,7 @@ import 'package:buzz/features/activity/inbox_unread_provider.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/profile/profile_edit_page.dart';
|
||||
import 'package:buzz/features/channels/channels_page.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/me/default_agent.dart';
|
||||
import 'package:buzz/shared/relay/app_lifecycle_provider.dart';
|
||||
@@ -159,6 +161,48 @@ void main() {
|
||||
expect(homeBuilds, 0);
|
||||
});
|
||||
|
||||
for (final (name, size, rightInset, platform) in [
|
||||
('phone', const Size(402, 874), 0.0, null),
|
||||
('iOS Duo', const Size(951, 669), 76.0, TargetPlatform.iOS),
|
||||
]) {
|
||||
testWidgets('a sheet opening above Home does not rebuild its inventory '
|
||||
'($name)', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.viewPadding = FakeViewPadding(right: rightInset);
|
||||
tester.view.padding = tester.view.viewPadding;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(await buildHome());
|
||||
await tester.pumpAndSettle();
|
||||
final rebuilds = <String>[];
|
||||
debugOnRebuildDirtyWidget = (element, _) {
|
||||
final type = '${element.widget.runtimeType}';
|
||||
if (const {'HomePage', 'ChannelsPage', '_ChannelTile'}.contains(type)) {
|
||||
rebuilds.add(type);
|
||||
}
|
||||
};
|
||||
addTearDown(() => debugOnRebuildDirtyWidget = null);
|
||||
|
||||
unawaited(
|
||||
showModalBottomSheet<void>(
|
||||
context: tester.element(find.byType(ChannelsPage)),
|
||||
useRootNavigator: true,
|
||||
builder: (_) => const Text('Sheet'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sheet'), findsOneWidget);
|
||||
Navigator.of(tester.element(find.text('Sheet'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
debugOnRebuildDirtyWidget = null;
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
|
||||
expect(rebuilds, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('feedback clears floating navigation and wraps large text', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user