mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Floating dates in conversations and threads now appear during reader scrolling, then fade to zero over 250 ms after one second idle. Automatic positioning stays quiet, and renewed scrolling reveals the date immediately. Reduced-motion behavior is preserved. Validation: mobile analysis, scroll/timing widget tests, and iPhone Air preview. Full mobile tests run through the push gate. Home and Inbox styling has moved to [#9187](https://github.com/block/buzz/pull/9187); this diff only contains scrolling-date behavior and its supporting tests/helper extraction. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
243 lines
7.7 KiB
Dart
243 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
import '../../shared/theme/theme.dart';
|
|
import 'message_action_backdrop_state.dart';
|
|
|
|
/// The active date and vertical push-off applied to a sticky date header.
|
|
@immutable
|
|
class StickyDateHeaderState {
|
|
final String? label;
|
|
final double translateY;
|
|
|
|
const StickyDateHeaderState({this.label, this.translateY = 0});
|
|
|
|
static const hidden = StickyDateHeaderState();
|
|
|
|
bool get isVisible => label != null;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is StickyDateHeaderState &&
|
|
other.label == label &&
|
|
other.translateY == translateY;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(label, translateY);
|
|
}
|
|
|
|
/// A glass date capsule that remains below the app bar as its day scrolls.
|
|
class StickyDateHeader extends StatelessWidget {
|
|
final ValueListenable<StickyDateHeaderState> state;
|
|
final ValueListenable<bool> scrollVisible;
|
|
|
|
const StickyDateHeader({
|
|
required this.state,
|
|
required this.scrollVisible,
|
|
super.key,
|
|
});
|
|
|
|
static const _iosViewType = 'buzz/sticky_date_glass';
|
|
static const _minimumIosGlassHeight = 28.0;
|
|
|
|
/// Height used by the surface and the next-day push-off calculation.
|
|
static double heightOf(BuildContext context) {
|
|
final labelStyle = context.textTheme.labelMedium;
|
|
final unscaledLineHeight =
|
|
(labelStyle?.fontSize ?? 14) * (labelStyle?.height ?? 1.25);
|
|
final contentHeight =
|
|
MediaQuery.textScalerOf(context).scale(unscaledLineHeight) + Grid.xxs;
|
|
return defaultTargetPlatform == TargetPlatform.iOS
|
|
? max(_minimumIosGlassHeight, contentHeight)
|
|
: contentHeight;
|
|
}
|
|
|
|
Widget _buildIosGlass(BuildContext context, String label) {
|
|
final textStyle = context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
);
|
|
final textPainter = TextPainter(
|
|
text: TextSpan(text: label, style: textStyle),
|
|
maxLines: 1,
|
|
textDirection: Directionality.of(context),
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
)..layout();
|
|
|
|
return _IosStickyDateGlass(
|
|
label: label,
|
|
width: textPainter.width + Grid.sm,
|
|
height: heightOf(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildFlutterSurface(BuildContext context, String label) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.12),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
child: ConstrainedBox(
|
|
key: const ValueKey('channel-sticky-date-header-surface'),
|
|
constraints: BoxConstraints(minHeight: heightOf(context)),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface.withValues(alpha: 0.82),
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
border: Border.all(
|
|
color: context.colors.onSurface.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.twelve,
|
|
vertical: Grid.half,
|
|
),
|
|
child: Semantics(
|
|
header: true,
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
|
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge([state, scrollVisible]),
|
|
builder: (context, _) {
|
|
final value = state.value;
|
|
final visible = value.isVisible && scrollVisible.value;
|
|
final stickyHeight = heightOf(context);
|
|
return SizedBox(
|
|
height: stickyHeight,
|
|
child: IgnorePointer(
|
|
child: ExcludeSemantics(
|
|
excluding: !visible,
|
|
child: AnimatedOpacity(
|
|
duration: reducedMotion || visible
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 250),
|
|
curve: Curves.easeInOut,
|
|
opacity: visible ? 1 : 0,
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: messageActionBackdropActive,
|
|
builder: (context, backdropActive, _) {
|
|
final useNativeGlass =
|
|
defaultTargetPlatform == TargetPlatform.iOS &&
|
|
!backdropActive;
|
|
final surface = Center(
|
|
child: RepaintBoundary(
|
|
child: useNativeGlass
|
|
? _buildIosGlass(context, value.label ?? '')
|
|
: _buildFlutterSurface(context, value.label ?? ''),
|
|
),
|
|
);
|
|
|
|
final pushOffProgress =
|
|
(-value.translateY / (stickyHeight + 5)).clamp(
|
|
0.0,
|
|
1.0,
|
|
);
|
|
return Opacity(
|
|
key: const ValueKey('sticky-date-push-off-opacity'),
|
|
opacity: 1 - pushOffProgress,
|
|
child: surface,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _IosStickyDateGlass extends HookWidget {
|
|
final String label;
|
|
final double width;
|
|
final double height;
|
|
|
|
const _IosStickyDateGlass({
|
|
required this.label,
|
|
required this.width,
|
|
required this.height,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final nativeChannel = useState<MethodChannel?>(null);
|
|
final brightness = context.theme.brightness.name;
|
|
|
|
useEffect(() {
|
|
final channel = nativeChannel.value;
|
|
if (channel != null) {
|
|
unawaited(channel.invokeMethod<void>('setLabel', label));
|
|
unawaited(channel.invokeMethod<void>('setBrightness', brightness));
|
|
}
|
|
return null;
|
|
}, [nativeChannel.value, label, brightness]);
|
|
|
|
return Semantics(
|
|
header: true,
|
|
label: label,
|
|
child: ExcludeSemantics(
|
|
child: SizedBox(
|
|
key: const ValueKey('channel-sticky-date-header-surface'),
|
|
width: width,
|
|
height: height,
|
|
child: UiKitView(
|
|
key: const ValueKey('channel-sticky-date-header-ios-glass'),
|
|
viewType: StickyDateHeader._iosViewType,
|
|
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
|
|
creationParams: <String, Object>{
|
|
'label': label,
|
|
'brightness': brightness,
|
|
},
|
|
creationParamsCodec: const StandardMessageCodec(),
|
|
onPlatformViewCreated: (viewId) {
|
|
nativeChannel.value = MethodChannel(
|
|
'${StickyDateHeader._iosViewType}/$viewId',
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|