mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
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>
196 lines
6.5 KiB
Dart
196 lines
6.5 KiB
Dart
import 'package:duo_kit/duo_kit.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import 'ios_navigation_bar.dart';
|
|
|
|
/// Rail top clearance on Duo's inner display until UIKit reports its regions.
|
|
/// The package estimate describes the cover display's taller status cluster.
|
|
const double duoInnerDisplayEstimatedRailClearance = 110;
|
|
|
|
/// Whether Duo controls should use the compact cover arrangement.
|
|
bool isCompactDuoWindow(BuildContext context) {
|
|
// Subscribe to resizing even inside a pane whose MediaQuery is narrower.
|
|
MediaQuery.sizeOf(context);
|
|
final view = View.of(context);
|
|
return (view.physicalSize / view.devicePixelRatio).shortestSide < 600;
|
|
}
|
|
|
|
/// Carries window geometry through the independently sized conversation panes.
|
|
class DuoToolbarScope extends InheritedModel<Object> {
|
|
const DuoToolbarScope({
|
|
required this.strip,
|
|
required this.active,
|
|
this.contentInsets = EdgeInsets.zero,
|
|
this.leadingSlots = 0,
|
|
required super.child,
|
|
super.key,
|
|
});
|
|
|
|
final FoldStrip? strip;
|
|
final bool active;
|
|
final EdgeInsets contentInsets;
|
|
|
|
/// Space above detail actions for the workspace's persistent rail controls.
|
|
final int leadingSlots;
|
|
|
|
static const Object _stripAspect = #strip;
|
|
|
|
static DuoToolbarScope? maybeOf(BuildContext context) =>
|
|
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) =>
|
|
strip != oldWidget.strip ||
|
|
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.
|
|
EdgeInsets duoContentInsetsOf(BuildContext context) {
|
|
final pane = DuoToolbarScope.maybeOf(context);
|
|
if (pane != null) return pane.contentInsets;
|
|
final strip = FoldGeometry.of(context).strip;
|
|
return EdgeInsets.only(
|
|
left: strip?.side == FoldStripSide.left ? strip!.width : 0,
|
|
right: strip?.side == FoldStripSide.right ? strip!.width : 0,
|
|
);
|
|
}
|
|
|
|
/// Projects the current route's controls into the window's side strip.
|
|
/// The portal retains route ownership and inherited providers for callbacks.
|
|
class DuoToolbarPortal extends HookConsumerWidget {
|
|
const DuoToolbarPortal({
|
|
required this.strip,
|
|
required this.active,
|
|
required this.actions,
|
|
required this.child,
|
|
super.key,
|
|
});
|
|
|
|
final FoldStrip? strip;
|
|
final bool active;
|
|
final List<IosNavigationAction> actions;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final portal = useMemoized(() => OverlayPortalController()..show());
|
|
return OverlayPortal(
|
|
overlayLocation: OverlayChildLocation.rootOverlay,
|
|
controller: portal,
|
|
overlayChildBuilder: (context) {
|
|
final strip = this.strip;
|
|
if (strip == null || actions.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final windowSize =
|
|
View.of(context).physicalSize / View.of(context).devicePixelRatio;
|
|
// The package fallback describes the cover display. The inner display's
|
|
// status cluster is shorter; keep this estimated until regions arrive.
|
|
final metrics = IosNavigationMetrics.of(context);
|
|
final measuredTop = metrics.windowSize == windowSize
|
|
? metrics.railTopClearance
|
|
: null;
|
|
final actionOffset =
|
|
(DuoToolbarScope.maybeOf(context)?.leadingSlots ?? 0) * 60.0;
|
|
final topClearance =
|
|
(measuredTop != null
|
|
? measuredTop + 12
|
|
: strip.isClearanceEstimated && windowSize.shortestSide >= 600
|
|
? duoInnerDisplayEstimatedRailClearance
|
|
: strip.topClearance) +
|
|
actionOffset;
|
|
return Positioned(
|
|
left:
|
|
IosNavigationMetrics.of(context).railCenter(
|
|
View.of(context).physicalSize /
|
|
View.of(context).devicePixelRatio,
|
|
strip.rect.center.dx,
|
|
) -
|
|
strip.width / 2,
|
|
top: strip.rect.top + topClearance,
|
|
width: strip.width,
|
|
child: Offstage(
|
|
offstage: !active,
|
|
child: MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: true,
|
|
removeBottom: true,
|
|
removeLeft: true,
|
|
removeRight: true,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight:
|
|
(strip.rect.height -
|
|
topClearance -
|
|
strip.bottomClearance -
|
|
220)
|
|
.clamp(0.0, double.infinity),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final action in actions)
|
|
SizedBox(
|
|
key: ValueKey('duo-toolbar-${action.label}'),
|
|
height: 60,
|
|
child: IosNavigationBar(
|
|
title: '',
|
|
standaloneControl: true,
|
|
leading: action,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Marks sheets whose default Close action is supplied by the Duo rail.
|
|
class DuoSheetCloseScope extends InheritedWidget {
|
|
const DuoSheetCloseScope({
|
|
required this.inRail,
|
|
required super.child,
|
|
super.key,
|
|
});
|
|
|
|
final bool inRail;
|
|
|
|
static bool of(BuildContext context) =>
|
|
context
|
|
.dependOnInheritedWidgetOfExactType<DuoSheetCloseScope>()
|
|
?.inRail ??
|
|
false;
|
|
|
|
@override
|
|
bool updateShouldNotify(DuoSheetCloseScope oldWidget) =>
|
|
inRail != oldWidget.inRail;
|
|
}
|