mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Duo keeps conversations and Settings details open across closed, book and flat postures. Book mode splits at the fold; flattening restores the sidebar. Native rail controls, full-width navigation backdrops and sheet-local safe areas keep content aligned. Ordinary iPhones remain single-pane in portrait and landscape; tablet and Duo split layouts remain enabled. Includes the recovered preview's matching pane widths, Buzz empty-pane mark, stable Settings chevrons, Bestie pane routing and Duo-only upward New menu attachment. Current main's Activity redesign, Search content fade and unread-navigation button are retained. Menu selection checks sit beside their labels. Shared mobile changes remain in scope: the native iOS status editor, Appearance preview layout, avatar crop preservation, onboarding footer layout, profile/invite sheet geometry and nested conversation navigation. This PR is not exclusively Duo-gated. Nested navigator observers preserve recording cancellation and navigation callbacks; crop resizing preserves the selected image region. iOS 27.1 APIs remain compile-time and runtime gated. Validation on the reconciled source: Flutter analysis, Activity/Home journeys, the full mobile test suite, file-size checks and Swift package tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro simulator before reconciliation. Fresh hosted CI and physical-device confirmation of the reconciled build are separate and remain pending. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com>
320 lines
12 KiB
Dart
320 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'ios_navigation_bar.dart';
|
|
import 'duo_toolbar.dart';
|
|
import 'ios_scroll_to_top.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
import '../theme/theme.dart';
|
|
import 'directional_transition_scope.dart';
|
|
import 'frosted_app_bar.dart';
|
|
import 'frosted_scroll_under_scope.dart';
|
|
|
|
/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body.
|
|
///
|
|
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
|
|
/// floating above it. The body is responsible for adding its own top spacing
|
|
/// using [frostedAppBarHeight] so content starts below the bar.
|
|
class FrostedScaffold extends HookWidget {
|
|
/// The frosted app bar displayed at the top of the screen.
|
|
final FrostedAppBar appBar;
|
|
|
|
/// The primary content of the scaffold. Must handle its own top spacing
|
|
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
|
|
final Widget body;
|
|
|
|
/// Non-scrolling content painted above the navigation bar and its blur.
|
|
/// Position this child within the scaffold's full-screen stack.
|
|
final Widget? foreground;
|
|
|
|
/// Optional floating action button, passed through to [Scaffold].
|
|
final Widget? floatingActionButton;
|
|
|
|
/// Whether the body should resize when the on-screen keyboard appears.
|
|
final bool? resizeToAvoidBottomInset;
|
|
|
|
/// Optional scaffold background, useful when a parent supplies a shared
|
|
/// surface behind this page.
|
|
final Color? backgroundColor;
|
|
|
|
/// A fixed gradient painted behind the app bar and scrolling body.
|
|
final Gradient? backgroundGradient;
|
|
|
|
/// Whether this settings-style page should invert the canvas and container
|
|
/// surface roles.
|
|
final bool useUtilitySurfaceTheme;
|
|
|
|
/// Reserves the native large title above fixed controls.
|
|
final bool nativePinnedBody;
|
|
|
|
/// Lets a conversation header span the rail while its body stays inset.
|
|
final bool extendHeaderUnderRail;
|
|
|
|
const FrostedScaffold({
|
|
super.key,
|
|
required this.appBar,
|
|
required this.body,
|
|
this.foreground,
|
|
this.floatingActionButton,
|
|
this.resizeToAvoidBottomInset,
|
|
this.backgroundColor,
|
|
this.backgroundGradient,
|
|
this.useUtilitySurfaceTheme = false,
|
|
this.nativePinnedBody = false,
|
|
this.extendHeaderUnderRail = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final contentInsets = duoContentInsetsOf(context);
|
|
final fullWidthHeader =
|
|
extendHeaderUnderRail || contentInsets != EdgeInsets.zero;
|
|
final scrollOffset = useValueNotifier(0.0);
|
|
final pinnedController = useScrollController(keepScrollOffset: false);
|
|
// A keyed page/tab owns both coordinated positions and their restoration
|
|
// bucket, so a new timeline cannot restore a stale inner offset.
|
|
final pinnedStorage = useMemoized(PageStorageBucket.new);
|
|
final pinnedKey = useMemoized(GlobalKey<NestedScrollViewState>.new);
|
|
final collapseExtent = appBar.nativeLargeTitle
|
|
? IosNavigationMetrics.of(context).largeTitleHeight
|
|
: 0.0;
|
|
final previousExtent = useRef(collapseExtent);
|
|
// Redistribute consumed distance after native metric changes without
|
|
// shifting the visible content or expanding a deeply scrolled title.
|
|
useEffect(() {
|
|
final old = previousExtent.value;
|
|
previousExtent.value = collapseExtent;
|
|
final inner = pinnedKey.currentState?.innerController;
|
|
if (old != collapseExtent &&
|
|
pinnedController.hasClients &&
|
|
pinnedController.offset >= old &&
|
|
inner != null &&
|
|
inner.hasClients &&
|
|
inner.offset > 0) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!pinnedController.hasClients || !inner.hasClients) return;
|
|
final total = pinnedController.offset + inner.offset;
|
|
pinnedController.jumpTo(collapseExtent.clamp(0.0, total));
|
|
inner.jumpTo((total - collapseExtent).clamp(0.0, double.infinity));
|
|
});
|
|
}
|
|
return null;
|
|
}, [collapseExtent]);
|
|
final nativePinned =
|
|
nativePinnedBody && defaultTargetPlatform == TargetPlatform.iOS;
|
|
useEffect(() {
|
|
void update() {
|
|
if (pinnedController.hasClients) {
|
|
final inner = pinnedKey.currentState?.innerController;
|
|
scrollOffset.value =
|
|
(pinnedController.offset +
|
|
(inner != null && inner.hasClients ? inner.offset : 0))
|
|
.clamp(0.0, double.infinity);
|
|
}
|
|
}
|
|
|
|
if (!nativePinned) return null;
|
|
pinnedController.addListener(update);
|
|
return () => pinnedController.removeListener(update);
|
|
}, [nativePinned, pinnedController]);
|
|
final isScrolledUnder = useState(false);
|
|
final pendingScrolledUnder = useRef<bool?>(null);
|
|
final scrollUpdateScheduled = useRef(false);
|
|
|
|
void updateScrollUnder(bool next) {
|
|
if (scrollUpdateScheduled.value) {
|
|
pendingScrolledUnder.value =
|
|
(pendingScrolledUnder.value ?? false) || next;
|
|
return;
|
|
}
|
|
pendingScrolledUnder.value = next;
|
|
scrollUpdateScheduled.value = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
scrollUpdateScheduled.value = false;
|
|
final pending = pendingScrolledUnder.value;
|
|
pendingScrolledUnder.value = null;
|
|
if (!context.mounted ||
|
|
pending == null ||
|
|
pending == isScrolledUnder.value) {
|
|
return;
|
|
}
|
|
isScrolledUnder.value = pending;
|
|
});
|
|
}
|
|
|
|
final observedBody = NotificationListener<Notification>(
|
|
onNotification: (notification) {
|
|
final ScrollMetrics metrics;
|
|
final int depth;
|
|
final BuildContext? sourceContext;
|
|
if (notification is ScrollNotification) {
|
|
metrics = notification.metrics;
|
|
depth = notification.depth;
|
|
sourceContext = notification.context;
|
|
} else if (notification is ScrollMetricsNotification) {
|
|
// Initial layout and content-size changes matter even before a drag.
|
|
metrics = notification.metrics;
|
|
depth = notification.depth;
|
|
sourceContext = notification.context;
|
|
} else {
|
|
return false;
|
|
}
|
|
if (depth != 0 || metrics.axis != Axis.vertical) return false;
|
|
// EditableText has its own depth-zero scrollable. Composer edits and
|
|
// caret scrolling must not replace the page's scroll-under state.
|
|
if (sourceContext?.findAncestorWidgetOfExactType<EditableText>() !=
|
|
null) {
|
|
return false;
|
|
}
|
|
// Reversed chats start at zero at the newest message. Older content
|
|
// behind the top bar is measured by extentAfter, not scroll pixels.
|
|
final topContentDepth = metrics.axisDirection == AxisDirection.up
|
|
? metrics.extentAfter
|
|
: metrics.extentBefore;
|
|
if (nativePinned &&
|
|
metrics.axis == Axis.vertical &&
|
|
pinnedController.hasClients) {
|
|
final inner = pinnedKey.currentState?.innerController;
|
|
scrollOffset.value =
|
|
(pinnedController.offset +
|
|
(inner != null && inner.hasClients ? inner.offset : 0))
|
|
.clamp(0.0, double.infinity);
|
|
}
|
|
if (!nativePinned && depth == 0 && metrics.axis == Axis.vertical) {
|
|
// Keep real depth so a later UIKit metrics update (Dynamic Type or
|
|
// rotation) can apply its new collapse range without another scroll.
|
|
// Lazy reversed lists can report an unbounded extentAfter. The
|
|
// visible viewport is enough depth for UIKit's collapsed state,
|
|
// while infinity is not a valid UIScrollView content offset.
|
|
final finiteDepth = topContentDepth.isFinite
|
|
? topContentDepth
|
|
: metrics.viewportDimension;
|
|
final next = finiteDepth.clamp(0.0, double.infinity);
|
|
if (next.isFinite && (scrollOffset.value - next).abs() > 0.1) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) scrollOffset.value = next;
|
|
});
|
|
}
|
|
}
|
|
final next = topContentDepth > 0.5;
|
|
if (next != isScrolledUnder.value) updateScrollUnder(next);
|
|
return false;
|
|
},
|
|
child: body,
|
|
);
|
|
final scaffold = IosScrollToTop(
|
|
onScrollToTop: nativePinned
|
|
? () {
|
|
if (!pinnedController.hasClients) return;
|
|
if (MediaQuery.disableAnimationsOf(context)) {
|
|
pinnedController.jumpTo(0);
|
|
} else {
|
|
pinnedController.animateTo(
|
|
0,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
}
|
|
: null,
|
|
child: IosNavigationScrollScope(
|
|
offset: scrollOffset,
|
|
child: Scaffold(
|
|
backgroundColor: backgroundColor,
|
|
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
|
floatingActionButton: floatingActionButton,
|
|
body: FrostedScrollUnderScope(
|
|
isScrolledUnder: isScrolledUnder.value,
|
|
child: Padding(
|
|
padding: fullWidthHeader ? EdgeInsets.zero : contentInsets,
|
|
child: Stack(
|
|
children: _stackChildren(
|
|
observedBody,
|
|
pinnedController,
|
|
pinnedStorage,
|
|
pinnedKey,
|
|
fullWidthHeader ? contentInsets : EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (!useUtilitySurfaceTheme) return scaffold;
|
|
return Theme(
|
|
data: utilitySurfaceThemeData(Theme.of(context)),
|
|
child: scaffold,
|
|
);
|
|
}
|
|
|
|
List<Widget> _stackChildren(
|
|
Widget observedBody,
|
|
ScrollController pinnedController,
|
|
PageStorageBucket pinnedStorage,
|
|
GlobalKey<NestedScrollViewState> pinnedKey,
|
|
EdgeInsets bodyInsets,
|
|
) {
|
|
final backdrop = backgroundGradient == null
|
|
? const <Widget>[]
|
|
: [
|
|
Positioned.fill(
|
|
child: _PinnedGradientBackground(gradient: backgroundGradient!),
|
|
),
|
|
];
|
|
final bodyMotion = DirectionalTransitionMotion(
|
|
transformKey: const ValueKey(
|
|
'frosted-scaffold-body-transition-transform',
|
|
),
|
|
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
|
|
child: nativePinnedBody && defaultTargetPlatform == TargetPlatform.iOS
|
|
? PageStorage(
|
|
bucket: pinnedStorage,
|
|
child: NestedScrollView(
|
|
key: pinnedKey,
|
|
controller: pinnedController,
|
|
headerSliverBuilder: (context, innerScrolled) => [
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: appBar.nativeLargeTitle
|
|
? IosNavigationMetrics.of(context).largeTitleHeight
|
|
: 0,
|
|
),
|
|
),
|
|
],
|
|
body: observedBody,
|
|
),
|
|
)
|
|
: observedBody,
|
|
);
|
|
// The bar must be painted after the scrollable sheet: [BackdropFilter]
|
|
// only samples pixels that were already painted behind it. This is the
|
|
// same composition as channel navigation, so top-level headers blur their
|
|
// content rather than only the fixed gradient.
|
|
return [
|
|
...backdrop,
|
|
Padding(padding: bodyInsets, child: bodyMotion),
|
|
appBar,
|
|
if (foreground != null)
|
|
Positioned.fill(
|
|
child: Padding(
|
|
padding: bodyInsets,
|
|
child: DirectionalTransitionMotion(child: foreground!),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
class _PinnedGradientBackground extends StatelessWidget {
|
|
final Gradient gradient;
|
|
|
|
const _PinnedGradientBackground({required this.gradient});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => DecoratedBox(
|
|
key: const ValueKey('frosted-scaffold-pinned-gradient'),
|
|
decoration: BoxDecoration(gradient: gradient),
|
|
);
|
|
}
|